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 的设计:

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