当我们将组件库公开发布后, 可以被任何国家任何人使用, 我们除了要有完善可靠的功能, 还要考虑诸如 国际化 / ts
类型定义 / 主题 等可以提升用户体验的东西
目录
前言
在 使用 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
源码, 查看具体是如何实现的:
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
属性: