1664 字
8 分钟
使用 Lit 创建一个 AI 对话组件库 04 国际化 篇

当我们将组件库公开发布后, 可以被任何国家任何人使用, 我们除了要有完善可靠的功能, 还要考虑诸如 国际化 / ts 类型定义 / 主题 等可以提升用户体验的东西

目录#

  1. 使用 Lit 创建一个 AI 对话组件库 01 搭建篇
  2. 使用 Lit 创建一个 AI 对话组件库 02 Prompts 篇
  3. 使用 Lit 创建一个 AI 对话组件库 03 可行性验证 篇
  4. 使用 Lit 创建一个 AI 对话组件库 04 国际化 篇

前言#

使用 Lit 创建一个 AI 对话组件库 03 可行性验证 篇 中我们发现了组件库存在以下问题:

  • 缺少 国际化 功能
  • 在 vue 中缺少 类型定义
  • 缺少 主题 功能

对于一个组件库来说, 我们的组件可能会被不同的项目使用, 如果我们不考虑多语言, 那就极大地限制了不同语言环境下的用户的使用, 所以我们需要考虑国际化; 对于类型定义和主题切换来说也是同样的道理

国际化#

shoelace 本身提供了 国际化支持, shoelace 是一个基于 Lit 的优秀的 UI 组件库, 我们阅读一下 shoelace 的源码, 借鉴一下 shoelace 的国际化实现:

我们从语言文件开始, 例如 src/translations/zh-cn.ts, 文件中使用了 src/utilities/localize.ts 中导出的 registerTranslation, registerTranslation 实际上来自于 @shoelace-style/localize 包, 原来 shoelace 将本地化功能拆分为了一个单独的包, 而且可以与 lit 完美结合, 零依赖!

@shoelace-style/localize 的示例:

import { LocalizeController, registerTranslation } from '@shoelace-style/localize';
// Note: translations can also be lazy loaded (see "Registering Translations" below)
import en from '../translations/en';
import es from '../translations/es';
registerTranslation(en, es);
@customElement('my-element')
export class MyElement extends LitElement {
private localize = new LocalizeController(this);
@property() lang: string;
render() {
return html`
<h1>${this.localize.term('hello_world')}</h1>
`;
}
}

这里我们引入了 @shoelace-style/localize 并注册了多语言文案 en / es, 接着我们在组件中声明了 localize = new LocalizeController(this)@property() lang: string, 最后在 render 中使用 this.localize.term('hello_world') 来获取文案, 他会在 lang 属性变化的时候自动更新多语言文案内容

这是一个优秀的设计, 我们仅需要修改 html[lang] 或 组件的 lang 属性, 组件就能根据新的语言进行渲染; 我们来猜一下 @shoelace-style/localize 是如何实现的, 首先 new LocalizeController(this) 时将当前组件类的示例传入了 LocalizeController, 并在 LocalizeController 内部监听 lang 属性的变化, 并且调用组件示例的用于渲染 DOM 的方法…

接下来让我们阅读 @shoelace-style/localize 源码, 查看具体是如何实现的:

localize/src/index.ts:

export class LocalizeController<UserTranslation extends Translation = DefaultTranslation>
implements ReactiveController
{
constructor(host: ReactiveControllerHost & HTMLElement) {
this.host = host;
this.host.addController(this);
}
// ...
}

首先在 constructor 中调用了组件的 addController, 这是一个用于向 LitElement 添加响应式控制器的方法, 它允许外部代码与 Lit 的响应式生命周期集成, 简而言之 LocalizeController 可以直接调用组件的生命周期 hooks, 也就可以直接更新组件, 详见 Reactive Controllers

const connectedElements = new Set<HTMLElement>();
if (isClient) {
const documentElementObserver = new MutationObserver(update);
documentDirection = document.documentElement.dir || 'ltr';
documentLanguage = document.documentElement.lang || navigator.language;
// Watch for changes on <html lang>
documentElementObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['dir', 'lang']
});
}
/** Updates all localized elements that are currently connected */
export function update() {
if (isClient) {
documentDirection = document.documentElement.dir || 'ltr';
documentLanguage = document.documentElement.lang || navigator.language;
}
[...connectedElements.keys()].map((el: LitElement) => {
if (typeof el.requestUpdate === 'function') {
el.requestUpdate();
}
});
}
// ...
export class LocalizeController<UserTranslation extends Translation = DefaultTranslation>
implements ReactiveController
{
hostConnected() {
connectedElements.add(this.host);
}
hostDisconnected() {
connectedElements.delete(this.host);
}
/**
* Gets the host element's language as determined by the `lang` attribute. The return value is transformed to
* lowercase.
*/
lang() {
return `${this.host.lang || documentLanguage}`.toLowerCase();
}
/** Outputs a translated term. */
term<K extends keyof UserTranslation>(key: K, ...args: FunctionParams<UserTranslation[K]>): string {
const { primary, secondary } = this.getTranslationData(this.lang());
// ...
}
// ...
}

在这里先把组件存到了 connectedElements 中, 然后在外部通过 MutationObserver 监听了 HTML 标签的 lang 属性变化, 并同步调用 update 调用组件的 requestUpdate() 来更新组件; 除此之外, 在组件内部的 lang 属性变化时, 也会调用 render, 并调用 term 来根据最新的 lang 更新组件

至此 @shoelace-style/localize 的示例的具体实现我们就分析完了, 接下来我们看一下 shoelace 是如何使用 @shoelace-style/localize 的:

src/internal/shoelace-element.ts:

export default class ShoelaceElement extends LitElement {
// Make localization attributes reactive
@property() dir: string;
@property() lang: string;
// ...
}

这里 shoelace 并没有在每个组件上都声明 lang / dir, 而是创建了一个基类 ShoelaceElement, 并在所有组件上继承了它, 这样 shoelace 就可以统一处理 lang / dir 了, 不得不说这是一个很优秀的设计

TIP

实际上 ShoelaceElement 类中还有很多值得学习的地方:

  • 类型安全的 emit 方法, 通过 函数重载 实现了 emit 方法的三种不同参数类型, 并实现了很好的类型约束
  • 组件的动态注册, define 方法实现了 动态注册重复注册检查
  • 动态依赖加载, 在 constructor 中动态注册组件声明的 dependencies, 详见 Cherry Pick - Shoelace

国际化实现与组件基类#

回到我们的组件库项目中, 我们来基于 @shoelace-style/localize 一步步实现本地化功能, 并且借鉴 ShoelaceElement 的设计:

Terminal window
code src/internal/shoelace-element.ts
import { LitElement } from "lit";
import { property } from "lit/decorators.js";
export default class ShoelaceElement extends LitElement {
// Make localization attributes reactive
@property() dir = 'ltr';
@property() lang = '';
}

tsconfig.json:

{
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
}

src/translations/zh-cn.ts:

import { registerTranslation, type Translation } from '@/translations/translation';
const translation: Translation = {
$code: 'zh-cn',
$name: '简体中文',
$dir: 'ltr',
test: '测试',
};
registerTranslation(translation);
export default translation;

src/translations/en.ts:

import { registerTranslation, type Translation } from '@/translations/translation';
const translation: Translation = {
$code: 'en',
$name: 'English',
$dir: 'ltr',
test: 'test',
};
registerTranslation(translation);
export default translation;

src/translations/translation.ts:

import type { Translation as DefaultTranslation } from '@shoelace-style/localize';
// Export functions from the localize lib so we have one central place to import them from
export { registerTranslation } from '@shoelace-style/localize';
export interface Translation extends DefaultTranslation {
$code: string; // e.g. en, en-GB
$name: string; // e.g. English, Español
$dir: 'ltr' | 'rtl';
test: string;
}

src/utils/localize.ts:

import { LocalizeController as DefaultLocalizationController, registerTranslation } from '@shoelace-style/localize';
import type { Translation } from '@/translations/translation';
import en from '.@/translations/en'; // Register English as the default/fallback language
import zhCn from '@/translations/zh-cn'; // Register English as the default/fallback language
/**
* Extend the controller and apply our own translation interface for better typings
* @see https://github.com/shoelace-style/shoelace/blob/next/src/utilities/localize.ts
*/
export class LocalizeController extends DefaultLocalizationController<Translation> {
// Technicallly '../translations/en.js' is supposed to work via side-effects. However, by some mystery sometimes the
// translations don't get bundled as expected resulting in `no translation found` errors.
// This is basically some extra assurance that our translations get registered prior to our localizer connecting in a component
// and we don't rely on implicit import ordering.
static {
registerTranslation(en);
registerTranslation(zhCn);
}
}

src/components/hyosan-chat.ts:

import ShoelaceElement from '@/internal/shoelace-element'
import { LocalizeController } from '@/utils/localize'
export class HyosanChat extends LitElement {
export class HyosanChat extends ShoelaceElement {
private _locailze = new LocalizeController(this)
render() {
return html`
<h2>${this.message}</h2>
<sl-button variant="primary">Hello Shoelace</sl-button>
<p>${this._locailze.term('test')}</p>
`
}
}

这里我们创建了一些本地化相关的类和函数, 并在组件中通过 LocalizeController 来实现本地化功能, 我们来测试一下

  • 修改组件的 lang 属性:

  • 修改 <html>lang 属性:

参考#

使用 Lit 创建一个 AI 对话组件库 04 国际化 篇
http://blog.xiaban.run/posts/2025/hyosan-chat-04-i18n/
作者
Ryan
发布于
2025-03-02
许可协议
CC BY-NC-SA 4.0