小玩具:基于观察者模式实现的数据驱动视图

143 阅读3分钟

之前,在通过栅格系统实现多屏幕适配时,突然有个想法:能否让所有的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}`);
        }),
    ]),
  ]);

counter.gif

媒体查询

再实现一个带媒体查询功能的状态主题:

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}`);
        }),
    ]),
  ]);

media-query.gif

制作出来的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}`);
        }),
    ]),
  ]);

computed.gif

写在最后

这只是一个小玩具,写得很粗糙,有很多方面都没有考虑。第一次写文章,如果不喜欢请忽略,别喷哈。