目录
概念
设置项目
创建装饰器
基类
完成笑脸元素
下一步是什么?
- 下载最新源(GitHub)
- 下载Tiny概念
下面的代码揭示了我脑海中的概念。它表示一个简单的Web组件,可根据输入显示不同大小的不同表情符号。
@element('my-smiley, ``)
class SmileyElement extends BaseElement {
@input()
type: string = 'happy';
@input()
size = 'small' | 'medium' | 'large' = 'medium';
@query('span')
spanEl;
onChanges(changes) {
// TODO: perform DOM operations
}
}
首先,我喜欢装饰器!我希望你也这样做。我想尽可能地利用它们来做可重用的业务。您可以在上面的代码片段中看到我们应用了一些装饰器,例如元素、输入和查询。
元素装饰器将类转换为Web组件。作为名称的输入装饰器用于将属性标记为输入。查询装饰器用于在访问应用属性时自动查询并返回子元素。我们可以通过装饰器获得更多乐趣,将事件自动绑定到函数怎么样?我们可以!让我们暂时保持简单。请查看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”的脚本引用,这很重要。
Simplifying Creating Web Components Using TypeScript Decorators
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;
}
My life of emotions
Demo of building UI components using native browser technologies leveraging typescript decorators. Please look into tiny library for real-world development of UI components.
创建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();
inputs = new Set();
}
accessors属性存储用于查询和输入的属性的详细信息以存储输入属性的详细信息。
在“lib”文件夹下创建一个名为“decorators.ts”的文件。让我们开始创建元素装饰器。
元素装饰器基本上接受选择器名称和可选的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-metdata 库来存储元数据,但我们在这里不这样做是为了避免生产依赖并保持我们的包大小最小。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);
};
}
输入装饰器有几个属性:attribute和dataType。属性属性表示输入属性值是否应最初从DOM元素属性中读取并应保持同步。dataType告诉我们在读取属性时需要正确解析值的属性类型。
查询装饰器接受一个参数,它只是选择器。我们的装饰器已经准备好,是时候编写基类了。
基类要将类转换为web组件,我们不仅要通过调用customElements.define方法来注册它,而且它还必须从原生HTMLElement或任何其他内置html元素扩展。我们将从HTMLElement扩展我们的基类。
创建一个名为“base-element.ts”的文件并将下面的类放入其中。
class BaseElement extends HTMLElement {
}
我们必须在这里做很多工作。首先,我们必须读取模板并渲染组件。其次,我们必须覆盖我们应用了装饰器的那些属性的getter和setter,以便组件可以检测到输入中是否有任何更改以刷新UI或在访问任何子元素时查询并返回子元素用查询装饰器装饰的属性。
总而言之,以下是我们需要做的主要事情。
- 从元数据中读取模板并渲染它。
- 覆盖装饰属性的getter和setter。
- 每次输入队列发生变化并触发计时器以在下一个滴答声中调用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。让我们看看如何让查询装饰器首先工作。查询装饰器将应用属性转换为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.
*/
$(selector: string, element: UIElement = this): T {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
if (el === this) {
return this.querySelector(selector);
}
if (el instanceof BaseElement) {
return el.$(selector);
}
return el.querySelector(selector) as T;
}
}
我们在_applyAccessors方法中所做的基本上是遍历每个应用的属性并覆盖getter以查询并返回与装饰器中传递的选择器匹配的子DOM元素。$方法返回组件的子元素或从匹配选择器的传递的父(元素)。
让我们看看如何使输入装饰器工作。这有点复杂。每次输入属性更改时,我们都必须接受更改并将其推送到内部队列并触发计时器以使用setTimeout在下一个滴答中更新UI。不仅如此,如果属性标志为真,那么我们必须从DOM属性中读取初始值并使用dataType正确解析它。
function isVoid(val) {
return val === null || val === undefined;
}
export type ElementChanges = Map;
class BaseElement extends HTMLElement {
...
/**
* Changes of inputs.
*/
private _changes = new Map();
/**
* The current state of properties.
*/
private _props = new Map();
/**
* 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,
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,
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,
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,
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受保护方法,该方法采用未实现的更改参数,并且该方法应被派生类覆盖以使用辅助方法执行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() {}
我们已经完成了创建自定义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', ``)
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);
}
}
}
我们在输入装饰器中将属性参数作为true传递,以将这些值作为HTML中的属性传递。您可以看到我们正在检查输入参数(如类型或大小)是否更改并相应地更新DOM的onChanges方法。
让我们创建一个小的应用程序组件来渲染多个笑脸。
@element('my-app', `
`)
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。请给repo一个star,并随时fork它。您还可以尝试一些新的东西,例如如何在模板中构建简单的数据绑定,这样我们就不必进行手动DOM操作。继续尝试吧!构建自己的东西并在实际应用程序中使用它非常有趣。
https://www.codeproject.com/Articles/5318708/How-to-create-a-simple-UI-library-using-decorators