之前,在通过栅格系统实现多屏幕适配时,突然有个想法:能否让所有的UI控件属性都支持根据断点系统设置?
Widget.div
.className('container')
.style('width', when({ xs: '100%', md: '50%', lg: '25%' })
.style('height', when({ xs: '100px', md: '50px', lg: '25px' })
.appendTo(document.querySelector('#app'));
于是,萌生了基于观察者模式实现的数据驱动视图的想法。只要UI控件属性接收到的数据类型是观察者模式中的Subject,那么就将修改对应UI控件属性的动作封装成Observer对象来订阅该Subject。
基本功能
所以,先实现观察者模式的基础框架代码:
/**
* 观察者
*/
export type IObserver<T> = (state: T, prevState?: T) => void;
export type IAttachDependencyOptions = { immediate?: boolean };
/**
* 主题:被观察的对象
*/
export class Subject<T> {
/**
* 主题状态,当此状态发生变化时通知所有的观察者数据变化
*/
state: T;
/**
* 观察者列表
*/
observers: Set<IObserver<T>>;
constructor(initState: T) {
// 初始化状态
this.state = initState;
// 初始化观察者列表
this.observers = new Set<IObserver<T>>();
}
/**
* 更新状态
* @param state 新状态
*/
public set(state: T): void {
// 通知所有的观察者数据发生了变化
for (const observer of this.observers) {
observer.call(undefined, state, this.state);
}
// 更新状态
this.state = state;
}
/**
* 建立关联关系
* @param observer 观察者
* @param options 绑定选项
*/
public attach(
observer: IObserver<T>,
options: IAttachDependencyOptions = { immediate: false }
): void {
this.observers.add(observer);
if (options.immediate) {
observer.call(undefined, this.state);
}
}
/**
* 解除关联关系
* @param observer 观察者
*/
public detach(observer: IObserver<T>): void {
this.observers.delete(observer);
}
}
/**
* 创建UI控件状态主题
* @param initState 初始状态
* @returns 主题
*/
export const state = <T>(initState: T): Subject<T> => {
return new Subject(initState);
};
然后将HTMLElement封装成Widget,提供方法来接受并订阅主题:
import { Subject, type IObserver } from "./state";
class Widget<T extends HTMLElement> {
constructor(public element: T) {}
static of<T extends HTMLElement>(element: T) {
return new Widget(element);
}
/**
* 添加子控件
* @param callback 回调函数
* @returns 子控件
*/
public children(
callback: (this: Widget<T>) => Widget<HTMLElement>[]
): Widget<T> {
const widgets = callback.call(this);
// 这里偷懒了,每次都是追加子控件上去
for (const widget of widgets) {
widget.appendTo(this);
}
return this;
}
/**
* 添加到父控件上
* @param parent 父控件
* @returns 控件自身
*/
public appendTo(parent: HTMLElement | Widget<HTMLElement>): Widget<T> {
if (parent instanceof HTMLElement) {
parent.appendChild(this.element);
} else {
this.appendTo(parent.element);
}
return this;
}
/**
* 事件监听
* @param type 事件类型
* @param listener 事件回调
* @returns 控件自身
*/
public on<K extends keyof HTMLElementEventMap>(
type: K,
listener: (this: HTMLElement, event: HTMLElementEventMap[K]) => any
): Widget<T> {
this.element.addEventListener(type, listener);
return this;
}
/**
* 属性设置
* @param key 属性名
* @param value 属性值
* @returns 控件自身
*/
public attr<K extends keyof T, V extends T[K]>(
key: K,
value: V | Subject<V>
): Widget<T> {
if (typeof this.element[key] === "undefined") return this;
const observer: IObserver<V> = (state) => {
this.element[key] = state;
};
return this.attach(value, observer);
}
public className(className: string | Subject<string>): Widget<T> {
return this.attr("className", className);
}
public text(content: string | Subject<string>): Widget<T> {
return this.attr("textContent", content);
}
/**
* 样式设置
* @param key 样式名
* @param value 样式值
* @returns 控件自身
*/
public style<
K extends keyof CSSStyleDeclaration,
V extends CSSStyleDeclaration[K]
>(key: K, value: V | Subject<V>): Widget<T> {
const observer: IObserver<V> = (state) => {
this.element.style[key] = state;
};
return this.attach(value, observer);
}
private attach<S>(state: S | Subject<S>, updater: IObserver<S>): Widget<T> {
if (state instanceof Subject) {
state.attach(updater, { immediate: true });
} else {
updater.call(this, state);
}
return this;
}
}
type HTMLElementTagName = keyof HTMLElementTagNameMap;
// 这里偷懒了,没有罗列出所有的HTMLElement的标签名
const tagNames: HTMLElementTagName[] = ["div", "button", "span", "input", "a"];
// 逐一将tagNames设置成Widget的静态属性,getter直接返回新的Widget实例可以减少一次()调用
tagNames.forEach((name) => {
Object.defineProperty(Widget, name, {
get() {
return new Widget(document.createElement(name));
},
});
});
export default Widget as typeof Widget & {
[key in HTMLElementTagName]: Widget<HTMLElementTagNameMap[key]>;
};
简单的测试用例,测试一下:
const app: HTMLElement = document.querySelector("#app")!;
const counter = state("1");
Widget.of(app)
.children(() => [
Widget.span.text(counter),
Widget.div.className("btn-group").children(() => [
Widget.button
.className("btn")
.text("增")
.on("click", () => {
counter.update((value) => `${parseInt(value, 10) + 1}`);
}),
Widget.button
.className("btn")
.text("减")
.on("click", () => {
counter.update((value) => `${parseInt(value, 10) - 1}`);
}),
]),
]);
媒体查询
再实现一个带媒体查询功能的状态主题:
export class MediaQuerySubject<T> extends Subject<T> {
constructor(query: string, matches: T, notMatches: T) {
const listeners = matchMedia(query);
super(listeners.matches ? matches : notMatches);
listeners.addEventListener("change", (event) => {
this.set(event.matches ? matches : notMatches);
});
}
}
export const match = <T>(query: string, matches: T, notMatches: T) => {
return new MediaQuerySubject(query, matches, notMatches);
};
测试一下:
const app: HTMLElement = document.querySelector("#app")!;
const counter = state("1");
Widget.of(app)
.style("flexDirection", match("(min-width: 1024px)", "row", "column"))
.children(() => [
Widget.span.text(counter),
Widget.div.className("btn-group").children(() => [
Widget.button
.className("btn")
.text("增")
.on("click", () => {
counter.update((value) => `${parseInt(value, 10) + 1}`);
}),
Widget.button
.className("btn")
.text("减")
.on("click", () => {
counter.update((value) => `${parseInt(value, 10) - 1}`);
}),
]),
]);
制作出来的gif不知道为什么出现了部分白色区域变成黄色,大家将就着看哈。
数据形变
如果数据需要形变再展示的话,可以在Subject上实现一个map方法,返回一个新的Subject。
export class Subject<T> {
public map<T2>(transform: (state: T) => T2): Subject<T2> {
const mapState = new Subject(transform(this.state));
const observer: IObserver<T> = (state) => {
mapState.set(transform(state));
};
this.attach(observer);
return mapState;
}
}
其他代码省略。
测试一下:
const app: HTMLElement = document.querySelector("#app")!;
const counter = state("1");
Widget.of(app)
.children(() => [
Widget.span.text(counter),
Widget.span.text(
counter.map((value) => {
console.log("map transform called.");
return `${parseInt(value, 10) * 2}`;
})
),
Widget.div.className("btn-group").children(() => [
Widget.button
.className("btn")
.text("增")
.on("click", () => {
counter.update((value) => `${parseInt(value, 10) + 1}`);
}),
Widget.button
.className("btn")
.text("减")
.on("click", () => {
counter.update((value) => `${parseInt(value, 10) - 1}`);
}),
]),
]);
写在最后
这只是一个小玩具,写得很粗糙,有很多方面都没有考虑。第一次写文章,如果不喜欢请忽略,别喷哈。