如何使用装饰器创建简单的 UI 库
在本文中,我想分享一些我在使用TypeScript装饰器构建UI库过程中获得的经验。
概念
以下代码揭示了我脑海中的概念。它代表了一个简单的Web组件,该组件根据输入显示不同大小的不同表情符号。
@element('my-smiley, `<span></span>`)
class SmileyElement extends BaseElement {
@input()
type: string = 'happy';
@input()
size = 'small' | 'medium' | 'large' = 'medium';
@query('span')
spanEl;
onChanges(changes) {
// TODO: perform DOM operations
}
}
首先,我爱装饰器!我希望你也一样。我想尽可能多地利用它们来实现可重用的业务逻辑。您可以在上面的片段中看到,我们应用了几个装饰器,如 element
、 input
和 query
。
element
装饰器将类转换为Web组件。input
装饰器顾名思义,用于将属性标记为输入。query
装饰器用于在访问应用属性时自动查询并返回子元素。我们可以用装饰器做更多有趣的事情,比如用一个自动将事件绑定到函数的装饰器?是的,我们可以!暂时保持简单。请查看tiny github存储库以参考更多装饰器。
另一个需要注意的重要事项是, SmileyElement
继承自基类 BaseElement
。为了使装饰器正常工作,我们需要做一些配置工作,而且我们还有其他工作要做……例如渲染传入的模板、用于操作DOM的辅助方法等。最重要的是,要将一个类注册为Web组件,它应该继承自 HTMLElement
或内置元素之一,而 BaseElement
继承自 HTMLElement
。
基类还提供了一些生命周期钩子,供组件拦截和执行操作。正如您所看到的,有一个 onChanges
方法,每次输入发生变化时都会调用它,您需要在该方法中执行DOM操作。由于我们没有那些酷炫的数据绑定,所以我们需要手动执行DOM更新。别担心,基类提供了许多辅助方法,使该过程更简单、更有效,并具有绝对的控制权。
好了,让我们设置项目,看看我们如何首先构建这些装饰器,然后是基类。
设置项目
选择您喜欢的编辑器(我喜欢WebStorm),然后创建一个名为“base-element”的新项目。我们需要TypeScript和Webpack来启动开发。首先,在终端中运行以下命令来初始化“package.json”。
npm init
系统会问您一系列问题,如项目名称、许可证等。随意输入详细信息,创建package.json文件后,运行以下命令来安装开发依赖项。
npm i typescript ts-loader webpack webpack-cli webpack-dev-server --save-dev
要配置TypeScript,您需要创建一个“tsconfig.json”文件。创建它并将以下内容粘贴到其中。需要注意的重要一点是 experimentalDecorators
标志,它应该设置为true才能使装饰器工作。此外,我们应该在 lib
属性中包含“es2018”、“dom”和“dom.iterable”包。
{
"compilerOptions": {
"baseUrl": "./",
"outDir": "dist",
"skipLibCheck": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"composite": true,
"declaration": true,
"declarationMap": true,
"forceConsistentCasingInFileNames": true,
"downlevelIteration": true,
"module": "commonjs",
"noEmitOnError": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noUnusedLocals": false,
"experimentalDecorators": true,
"noImplicitOverride": false,
"noImplicitAny": false,
"pretty": true,
"sourceMap": true,
"strict": true,
"strictNullChecks": false,
"target": "es2020",
"incremental": true,
"newLine": "LF",
"lib": ["es2018", "dom", "dom.iterable"]
},
"files": ["dev.ts"],
"include": [
"lib/**/*.ts"
]
}
为了测试我们的 SmileyElement
,我们需要一个“index.html”文件,当然还需要一个Web服务器来启动它。创建“index.html”文件并用以下内容填充。不要忘记对“app.js”的脚本引用,这很重要。
<html lang="en">
<head>
<title>Simplifying Creating Web Components Using TypeScript Decorators</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Pacifico&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Comfortaa:wght@300&family=Pacifico&display=swap" rel="stylesheet">
<style>
body {
margin: 0 auto;
display: flex;
text-align: center;
justify-content: center;
flex-direction: column;
background-color: #f9f9f9;
font-family: 'Comfortaa', cursive;
}
h1 {
font-family: 'Pacifico', cursive;
}
pre {
font-family: 'courier';
color: gray;
margin: 2rem 0;
}
footer {
color: gray;
font-size: 0.6rem;
}
</style>
</head>
<body>
<h1>My life of emotions</h1>
<main>
<div class="app-container"></div>
<pre>
<my-smiley type="happy"></my-smiley>
</pre>
<footer>
Demo of building UI components using native browser technologies leveraging typescript decorators.<br> Please look into <a target="_blank" href="https://github.com/vjai/tiny">tiny</a> library for real-world development of UI components.
</footer>
</main>
<script src="app.js"></script>
</body>
</html>
创建webpack文件(webpack.config.js)以运行开发服务器,这样我们就可以在 SmileyComponent
准备好后对其进行测试。
const path = require('path');
module.exports = {
mode: 'development',
entry: { app: './dev.ts' },
module: {
rules: [
{
test: /\.js[x]?$/,
exclude: /(node_modules)/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.ts$/,
exclude: /(node_modules)/,
use: {
loader: 'ts-loader'
}
}
]
},
resolve: {
modules: [path.resolve(__dirname), 'node_modules'],
extensions: ['.ts', '.js', '.jsx', '.json']
},
devServer: {
static: {
directory: path.resolve(__dirname)
}
},
devtool: 'source-map'
};
最后,在“package.json”的“scripts”部分添加以下命令来运行和构建项目。
"scripts": {
"start": "webpack-dev-server --config webpack.config.js",
"build": "tsc --build tsconfig.json"
}
呼……我们的项目设置完成了。让我们去创建装饰器。
创建装饰器
创建一个名为“lib”的新文件夹,用于存放我们所有的装饰器、基类和其他组件构建相关的东西。
装饰器有助于为类、方法或属性添加自定义元数据。这就是一个典型的类装饰器的样子,
export function decoratorName(...options): ClassDecorator {
return (target: any) => {
};
}
本质上它是一个返回函数的函数,是的,有点像闭包!我们将在这里使用装饰器来指定有关组件的元数据信息,如选择器名称、模板、输入、查询访问器等。
好了,首先让我们创建一个名为 ElementMetadata
的类来存储组件信息,如选择器名称、模板、输入和其他内容。在“lib”文件夹下创建一个名为“element.metadata.ts”的新文件,并将以下类放入其中。
export class ElementMetadata {
name: string = null;
tpl: string = null;
accessors = new Map<string, { selector: string; }>();
inputs = new Set<{property: string; attribute: boolean; dataType: AttributeValueDataType; }>();
}
accessors
属性存储用于查询的属性的详细信息,inputs
用于存储输入属性的详细信息。
在“lib”文件夹下创建一个名为“decorators.ts”的文件。让我们开始创建 element
装饰器。
element
装饰器接受选择器名称和一个可选的HTML模板,并将其存储在元数据中。此外,它还通过调用原生的 customElements.define
方法将其注册为Web组件。
/**
* Registers a class into web component.
* @param name selector name.
* @param [tpl] html template string.
*/
export function element(
name: string,
tpl?: string
): ClassDecorator {
return (target: any) => {
if (window.customElements.get(name)) {
throw new Error(`Already an element is registered with the name ${name}`);
}
window.customElements.define(name, target);
setMeta(target, Object.assign(getMeta(target), { name, tpl }));
};
}
function getMeta(target: Function) {
return target[ELEMENT_META_KEY] || new ElementMetadata();
}
function setMeta(target: Function, meta: ElementMetadata) {
target[ELEMENT_META_KEY] = meta;
}
元数据存储在组件类的静态属性中。我们也可以使用 reflect-metadata 库来存储元数据,但我们在这里不这样做是为了避免生产依赖,也是为了保持我们的包大小最小。 ELEMENT_META_KEY
是一个常量,您可以创建一个单独的常量文件来存放它,或者直接将其放在“element.metadata.ts”文件中。
export const ELEMENT_META_KEY = '__ELEMENT_INFO__'
让我们看看其他两个装饰器。它们很简单,它们所做的只是接受传入的参数并将其存储在元数据中。
/**
* Marks the applied property as an input.
* @param [attribute] True to bind the property with the attribute.
* @param [dataType] The data type of the attribute.
*/
export function input(attribute = false, dataType = AttributeValueDataType.STRING): PropertyDecorator {
return (target: object, property: string | symbol) => {
const metadata = getMeta(target.constructor),
{ inputs } = metadata;
if (inputs.has(property)) {
throw new Error(
`Input decorator is already applied for the property ${
property as string
}`
);
}
inputs.add({ property, attribute, dataType });
setMeta(target.constructor, metadata);
};
}
/**
* Marks the applied property as a CSS selector.
* @param selector CSS selector.
*/
export function query(selector: string): PropertyDecorator {
return (target: object, property: string | symbol) => {
const metadata = getMeta(target.constructor),
{ accessors } = metadata;
if (accessors.has(property)) {
throw new Error(
`Already a CSS selector is assigned for the property ${
property as string
}`
);
}
accessors.set(property, { selector });
setMeta(target.constructor, metadata);
};
}
input
装饰器接受几个属性: attribute
和 dataType
。attribute
属性表示是否应初始从DOM元素属性读取输入属性值并保持同步。dataType
告诉我们属性的类型,当从属性读取值时,我们需要正确地解析它。
query
装饰器接受一个参数,该参数就是选择器。我们的装饰器已准备就绪,现在是时候编写基类了。
基类
要将类转换为Web组件,我们不仅必须通过调用 customElements.define
方法来注册它,而且它还必须继承自原生的 HTMLElement
或任何其他内置的HTML元素。我们将把基类继承自 HTMLElement
。
创建一个名为“base-element.ts”的文件,并将以下类放入其中。
class BaseElement extends HTMLElement {
}
我们在这里需要做很多工作。首先,我们需要读取模板并渲染组件。其次,我们需要覆盖应用了装饰器的那些属性的getter和setter,以便组件能够检测到何时输入发生任何变化以刷新UI,或者在访问任何被query装饰器装饰的属性时查询并返回子元素。
总而言之,我们需要做的主要事情如下。
- 从元数据中读取模板并渲染它。
- 覆盖装饰器属性的getter和setter。
- 每次输入发生变化时,将该变化加入队列,并触发一个定时器,在下一个tick调用onChanges方法。
- 创建辅助方法来执行DOM操作,如添加/删除CSS类、添加/删除样式等。
好了,让我们先看看如何从元数据中读取模板并进行渲染。
/**
* Base class for all custom web components.
*/
class BaseElement extends HTMLElement {
/**
* The component metadata.
*/
private readonly _metadata: ElementMetadata = null;
/**
* True when the component is rendered.
*/
private _rendered: boolean = false;
protected constructor() {
super();
// Read the metadata from the constructor.
this._metadata = this.constructor[ELEMENT_META_KEY];
}
/**
* Native life-cycle hook.
*/
protected connectedCallback() {
// Call the render if the component is not rendered.
if (!this._rendered) {
this.render();
this._rendered = true;
}
}
/**
* Reads the template from metadata and renders the template.
*/
protected render() {
if (!this._metadata.tpl) {
return;
}
const template = document.createElement('template');
template.innerHTML = this._metadata.tpl;
this.appendChild(template.content.cloneNode(true));
}
}
需要注意的重要一点是,我们已经挂载到了Web组件的原生 connectedCallback
生命周期处理程序上,以渲染模板。我们还创建了一个标志 _rendered
来确保只渲染一次。
接下来,我们需要覆盖装饰器属性的getter和setter。让我们先看看如何实现 query
装饰器。query
装饰器将应用属性转换为CSS选择器,这意味着每次访问该属性时,它都会自动查询并返回匹配的DOM元素。为了实现这一点,我们需要覆盖属性的getter,以查询并返回与装饰器中CSS选择器匹配的DOM元素。
要在任何对象中覆盖属性的getter/setter,可以使用 Object.defineProperty
方法。
Object.defineProperty(obj, propName, {
get() {
// Override
},
set(value) {
// Override
},
});
这是我们更新后的代码。
export type UIElement = string | BaseElement | HTMLElement;
class BaseElement extends HTMLElement {
...
/**
* True when the component is initialized (applied the decorators and refreshed with the initial inputs state).
*/
private _initialized: boolean = false;
/**
* Overrides the getter of the properties decorated with `query` decorator to return the dom elements
* on accessing the properties.
*/
private _applyAccessors() {
[...this._metadata.accessors].forEach(
([prop, { selector }]) => {
Object.defineProperty(this, prop, {
get() {
return this.$(selector);
}
});
}
);
}
private _element(el: UIElement): UIElement {
if (arguments.length === 0 || el === 'self') {
return this;
}
if (el instanceof HTMLElement) {
return el;
}
return this.$(el as string);
}
protected connectedCallback() {
if (!this._rendered) {
this.render();
this._rendered = true;
}
if (!this._initialized) {
this._applyAccessors();
this._initialized = true;
}
}
/**
* Returns the DOM element for the passed selector.
* @param selector CSS selector.
* @param [element] Optional parent element. If not passed the element is queried inside the current component.
*/
$<T extends HTMLElement>(selector: string, element: UIElement = this): T {
const el = this._element(element) as HTMLElement;
if (!el) {
return <any>this;
}
if (el === this) {
return this.querySelector(selector);
}
if (el instanceof BaseElement) {
return el.$(selector);
}
return el.querySelector(selector) as T;
}
}
我们在 _applyAccessors
方法中所做的是遍历每个应用属性,并覆盖getter,以查询并返回与装饰器中传递的选择器匹配的子DOM元素。 $
方法返回组件的子元素或从传递的父元素(element)匹配选择器。
让我们看看如何实现 input
装饰器。这有点复杂。每次输入属性更改时,我们都需要捕获更改,将其推送到内部队列,并触发一个定时器,在下一个tick使用 setTimeout
来更新UI。不仅如此,如果 attribute
标志为 true
,那么我们就必须从DOM属性中读取初始值,并使用 dataType
正确地解析它。
function isVoid(val) {
return val === null || val === undefined;
}
export type ElementChanges = Map<string, { oldValue: any; newValue: any }>;
class BaseElement extends HTMLElement {
...
/**
* Changes of inputs.
*/
private _changes = new Map<string, { oldValue: any; newValue: any }>();
/**
* The current state of properties.
*/
private _props = new Map<string, any>();
/**
* Timer to refresh the UI from the changes map.
*/
private _updateTimer: any = null;
/**
* Overrides the getter and setter of the properties decorated with `input` decorator.
* The getter is overridden to return the current state from the `_props` property and the setter is
* overridden to track the change and push to the `changes` map eventually triggering the update timer to
* refresh the UI in the next tick.
*/
private _applyInputs() {
[...this._metadata.inputs].forEach(({ property, attribute, dataType }) => {
let value;
// If attribute is passed as `true` then read the initial value of the property from
// DOM attribute parse it based on the data type and store it in the `_props`.
if (attribute) {
let attrValue: any = this.getAttr(property);
if (attrValue !== null) {
if (
dataType === AttributeValueDataType.NUMBER &&
!isNaN(parseFloat(attrValue))
) {
attrValue = parseFloat(attrValue);
} else if (dataType === AttributeValueDataType.BOOLEAN) {
attrValue = attrValue === 'true' || attrValue === '';
}
value = attrValue;
} else {
value = this[property];
}
if (!isVoid(value) && value !== attrValue) {
this.setAttr({ [property]: value });
}
} else {
value = this[property];
}
this._pushChange(property, value);
this._props.set(property, value);
const target = this;
// Override the getter and setter.
// On setting a new value push the change and trigger the timer.
Object.defineProperty(this, property, {
get() {
return target._props.get(property);
},
set(value) {
if (attribute) {
if (value) {
target.setAttr({
[property]: !isVoid(value) ? value.toString() : value
});
} else {
target.removeAttr(property);
}
}
target._pushChange(property, value);
target._props.set(property, value);
target._initialized && target._triggerUpdate();
}
});
});
}
/**
* Checks if there is really a change if yes then push it to the `_changes` map.
* @param prop
* @param value
*/
private _pushChange(prop: string, value: any) {
if (!this._changes.has(prop)) {
this._changes.set(prop, { oldValue: this[prop], newValue: value });
return;
}
const { oldValue, newValue } = this._changes.get(prop);
if (oldValue === newValue && this._initialized) {
this._changes.delete(prop);
return;
}
this._changes.set(prop, { oldValue, newValue: value });
}
/**
* Kicks the UI update timer.
*/
private _triggerUpdate() {
if (this._updateTimer) {
return;
}
this._updateTimer = setTimeout(() => this.refresh(), 0);
}
protected connectedCallback() {
if (!this._rendered) {
this.render();
this._rendered = true;
}
if (!this._initialized) {
this._applyAccessors();
this._applyInputs();
this._initialized = true;
}
this.refresh();
}
/**
* Invoked whenever there is a change in inputs.
* @param changes
*/
protected onChanges(changes) {}
protected refresh() {
this.onChanges(this._changes);
this._changes.clear();
this._updateTimer && window.clearTimeout(this._updateTimer);
this._updateTimer = null;
}
}
_changes
属性用于存储输入更改的队列。_props
存储这些属性的最新值。这就是我们为了让装饰器工作所需要做的全部工作。
让我们添加一些帮助操作DOM的方法。
export interface KeyValue {
[key: string]: any;
}
import { isVoid } from "./util";
import { KeyValue, UIElement } from "./base-element";
class BaseElement extends HTMLElement {
...
/**
* Adds single or multiple css classes.
* @param classes
* @param [element]
*/
addClass(
classes: string | Array<string>,
element: UIElement = this
): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
el.classList.add(...(Array.isArray(classes) ? classes : [classes]));
return this;
}
/**
* Removes single or multiple css classes.
* @param classes
* @param [element]
*/
removeClass(
classes: string | Array<string>,
element: UIElement = this
): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
el.classList.remove(...(Array.isArray(classes) ? classes : [classes]));
return this;
}
/**
* Applies passed styles.
* @param styles
* @param [element]
*/
addStyle(styles: KeyValue, element: UIElement = this): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
Object.entries(styles).forEach(([k, v]) => {
if (k.startsWith('--')) {
el.style.setProperty(k, v);
} else if (v === null) {
this.removeStyles(k, el);
} else {
el.style[k] = v;
}
});
return this;
}
/**
* Removes passed styles.
* @param styles
* @param [element]
*/
removeStyles(
styles: string | Array<string>,
element: UIElement = this
): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
(Array.isArray(styles) ? styles : [styles]).forEach(
style => (el.style[style] = null)
);
return this;
}
/**
* Returns passed attribute's value.
* @param name
* @param [element]
*/
getAttr(name: string, element: UIElement = this): string {
const el = this._element(element) as HTMLElement;
if (!el) {
return '';
}
return el.getAttribute(name);
}
/**
* Sets the attributes.
* @param obj
* @param [element]
*/
setAttr(obj: KeyValue, element: UIElement = this): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
Object.entries(obj).forEach(([key, value]) =>
isVoid(value) ? this.removeAttr(key) : el.setAttribute(key, value)
);
return this;
}
/**
* Removes the passed attributes.
* @param attrs
* @param [element]
*/
removeAttr(
attrs: string | Array<string>,
element: UIElement = this
): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
(Array.isArray(attrs) ? attrs : [attrs]).forEach(attr =>
el.removeAttribute(attr)
);
return this;
}
/**
* Updates the inner html.
* @param html
* @param [element]
*/
updateHtml(html: string, element: UIElement = this): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
el.innerHTML = !isVoid(html) ? html : '';
return this;
}
}
您可以看到有一个受保护的 onChanges
方法,它接受一个未实现的 changes
参数,并且派生类应该覆盖此方法,使用辅助方法执行DOM操作。
我们的基类几乎准备好了,作为最后的润色,我们暴露了两个额外的生命周期钩子,派生类可以在元素连接到DOM或从DOM断开连接时使用它们来添加自定义逻辑。
/**
* Native life-cycle hook.
*/
protected connectedCallback() {
...
// Call our custom life-cycle hook method.
this.onConnected();
// Refresh the UI with the initial input property values.
this.refresh();
}
/**
* Native life-cycle hook.
*/
protected disconnectedCallback() {
this.onDisconnected();
}
/**
* Custom life-cycle hook meant to be overridden by derived class if needed.
*/
protected onConnected() {}
/**
* Custom life-cycle hook meant to be overridden by derived class if needed.
*/
protected onDisconnected() {}
完成Smiley Element
我们已经完成了创建自定义Web组件的所有工作。下面是我们之前看到的 SmileyElement
的完整代码,它根据传入的输入显示不同的表情符号。
import { BaseElement, element, ElementChanges, input, query } from './lib';
enum sizeRemMap {
'small' = 1,
'medium' = 2,
'large' = 3,
}
enum smileyMap {
'happy'= '😀',
'lol' = '😂',
'angel' = '😇',
'hero' = '😎',
'sad' = '😞',
'cry' = '😢',
'romantic' = 'ðŸ˜',
'sleep' = '😴',
'nerd' = '🤓'
}
@element('my-smiley', `<span></span>`)
class SmileyElement extends BaseElement {
@input(true)
type: string = 'happy';
@input(true)
size: 'small' | 'medium' | 'large' = 'medium';
@query('span')
spanEl;
onChanges(changes: ElementChanges) {
if (changes.has('type')) {
this.updateHtml(smileyMap[this.type || 'happy'], this.spanEl);
}
if (changes.has('size')) {
this.addStyle({ 'font-size': `${sizeRemMap[this.size]}rem`}, this.spanEl);
}
}
}
我们在 input
装饰器中将 attribute
参数设置为true,以便将这些值作为HTML属性传递。您可以看到 onChanges
方法,我们在其中检查类型或大小等输入参数是否已更改,并相应地更新DOM。
让我们创建一个小的App组件来渲染多个笑脸。
@element('my-app', `
<my-smiley type="happy"></my-smiley>
<my-smiley type="lol"></my-smiley>
<my-smiley type="angel"></my-smiley>
<my-smiley type="hero"></my-smiley>
<my-smiley type="sad"></my-smiley>
<my-smiley type="cry"></my-smiley>
<my-smiley type="romantic"></my-smiley>
<my-smiley type="sleep"></my-smiley>
<my-smiley type="nerd"></my-smiley>
`)
class App extends BaseElement {
}
最后,为了渲染App组件,我们需要将一个处理程序连接到文档的 DOMContentLoaded
事件。
document.addEventListener('DOMContentLoaded', () => {
const app = document.createElement('my-app');
document.querySelector('.app-container').appendChild(app);
});
最后,我们完成了开发。让我们启动“index.html”文件,并通过运行以下命令来查看一切是否正常工作。
npm start
如果一切顺利,您应该会看到下面的屏幕,
接下来呢?
这是创建我们自己的UI库的一个小尝试,使用纯粹的本地概念和利用装饰器来构建组件。对于实际使用,请查看 tiny 项目,该项目提供了更多的装饰器和大量的DOM操作辅助方法。请给仓库点个赞,并随时fork。您还可以尝试一些新的东西,比如如何在模板中构建简单的数据绑定,这样我们就无需进行手动DOM操作。去试试吧!创建自己的东西并在实际应用中使用它会非常有趣。
源代码: https://github.com/vjai/base-element
Tiny: https://github.com/vjai/tiny