65.9K
CodeProject 正在变化。 阅读更多。
Home

在 JavaScript(TypeScript 版)中介绍 MVVM 架构

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2020 年 4 月 23 日

MIT

23分钟阅读

viewsIcon

30142

本项目解释了 MVVM 设计模式在 JavaScript 前端应用中的实现。

引言

关于数据绑定的详细讨论在我之前的文章中,纯 JavaScript 中的双向数据绑定

在创建应用程序时,最重要的部分是决定开发语言、技术栈和架构模式。在创建 Web 应用程序时,语言选择是 JavaScript 或 TypeScript。以前,JavaScript 被视为一种弱编程语言。这给技术栈库打上了烙印。大多数好的库和实用程序都围绕 MVC 架构。MVP 较少,MVVM 更少。实际上,MVVM 在移动平台上虽然正在积极普及,但对于 Web 平台却完全被忽视。

数据像粒子,函数像波浪,从波浪中提取数据很容易,但将所有粒子聚集到一个函数中则要困难得多。

MVP 和 MVVM 相对 MVC 架构的低普及率是由多种因素造成的。从任何 JavaScript 开始,然后发展到 MVC 很容易。构建更好的 MVP 架构需要更多努力。而构建 MVVM 则需要更多的实践和努力。尽管如此,MVVM 架构作为原生应用程序在移动平台上越来越受欢迎。对于 Web 应用程序来说,它是一个罕见的主题。

MVVM 是游戏规则改变者

我相信 MVVM 架构(它只是一种设计模式)能够构建中大型应用程序。尤其是在现代 Web 技术方面。迟早,MVVM 设计将在 Web 上出现。通过这篇文章,我希望引领这一潮流。

让我们将 MVVM 设计模式分解成各个部分,讨论每一层、其参与者,并尝试构建一个完整的 MVVM 工作草案。从需求开始,通常是关于制作 ToDo 应用程序。技术栈——纯 JavaScript 和 WEB API。不再使用其他库。为了完成许多重复任务,本草案也将创建一个实用程序库。在 MVVM 中,视图和视图模型之间的通信通过数据绑定技术进行。这也会涉及。与后端通信将通过适配器设计模式进行模拟。控制反转(IoC)将最少提及。

MVVM 草案中的终极层

数据传输层将包含与 Web 应用程序和后端之间通信相关的所有内容。任何 `ajax`、`fetch`、`XMLHttpRequest` 以及与从后端请求和传输数据相关的所有其他内容都将存储在此层中。规则——适配器设计模式。数据类型,将与 `JSON.stringify` 一起使用的简单类型。在草案中,与后端的通信将进行模拟。

模型层将保留获取的数据,并将提供要推送到后端的数据。在此层中,应用程序可以读取或写入后端的全部内容将以易于在解决方案中使用的形式存储。所有需要为后端通信接口修改的数据也将在本层中进行调整。规则——模型,MVVM 设计模式的第一个字母。一小部分业务需求可能会存在。仅作为草稿版本,理由是它将在以后移到第三层。

ViewModel 层将在应用程序的业务逻辑中扮演重要角色。所有与实现需求相关的内容都将在此处。在此层中,模型和视图之间的通信将更加密集。规则——ViewModel,MVVM 设计模式的最后两个字母。该层将构建一组重要的核心命令,用于实现应用程序的逻辑。对于小型和中型应用程序,模型和 ViewModel 之间的分离可以最小化,按照开发过程组织。对于大型应用程序,这可能不够。最好规划 ViewModel 和模型之间更好的分离。工作单元(Unit Of Work)可以帮助构建一个额外的层,允许分组核心命令并提供更多空间来实施更多需求。

视图层是应用程序的可见形态。它是宝贵的 UI,它将允许客户有效地使用应用程序。这一部分是许多意外变化的最终目标。客户经常喜欢讨论 UI。特别是如果客户喜欢这个应用程序,他们将需要更好的 UI。它将是所有必需和要求实现的业务工作流的入口点。在此层中,视图模型之间的所有通信都将通过数据绑定规则进行。这将使我们能够与视图模型很好地分离。规则——视图,MVVM 设计模式中的第二个字母,命令——MVVM 设计模式中的命令。与 UI 相关的部分需求实现将在此处。

有两种视图,被动视图和主动视图。被动视图是不在选择正确视图模型中扮演主动角色的视图,只是读取状态并相应地更新 UI。主动视图可以拾取正确的视图模型并读取状态。对于小型和中型应用程序,保持被动视图是一个好主意。这意味着视图永远不会知道任何关于视图模型的信息。它只会绑定和解绑到/从提供的视图模型。视图和视图模型的关联通过 IoC 设置配置。当客户喜欢该解决方案时,总有一天会出现构建“不可能”的 UI/视图的需求。需求可能是这样的:_通过给定的核心解决方案,我(作为客户)希望构建自己的 UI_。简单的解决方案可能是根据 Facade 设计模式用命令 API 替换所有视图。这对于具有主动视图的应用程序可能是一个艰难的时期。负责做出关于正确视图的决策的每个逻辑都应该被提取并移出 Facade。如果留下,这可能会使维护这样的解决方案变得痛苦。在被动视图的情况下,命令 API 可以作为另一个视图层引入,并通过 IoC 设置调整到正确的视图模型。理想情况下……我认为……也许……恕我直言……

实用工具,多功能工具

对客户不可见但对开发者非常重要。完美的技术栈,能够排除代码重复、冗长方法名,可能引入新的开发方式的实用工具,让富有想象力的应用程序开发人员永不停歇,寻找更好的库来完成下一个“不可能”的任务。一套最小且有效的工具,将帮助以更少的痛苦、汗水和泪水解决重要任务。

MVVM 中的命令

命令在 ViewModel 中定义。命令适用于除获取和显示数据之外的所有内容。它是视图和视图模型分离中的另一个小型子层。如果任务是更新 ViewModel 上的状态,将 UI 中的输入值传输到后端,使用新数据更新或刷新 UI,则命令是运行此类任务的良好入口点。除了 `exec` 方法之外,命令还可以有更多方法,例如 `canExecute` 或 `inProgress`。第一个用于启用/禁用 UI 上的按钮。第二个用于在 UI 上实现异步加载进度叠加。

与后端通信

它将被组织成一种方式,以保持数据传输类型与来自远程服务端点的类型一致。不转换为将要传输的类型。只有一个规则——方便接收和传输数据。方法只是重复后端服务端点。通过这种方法,将很容易调查与后端相关的问题,找到所有使用的端点路径名,以及用于传输/接收数据的技术。后端数据联系会随时间变化。在这种情况下,所有数据都将保持原样,但是,如果发生任何更改,将很容易确定差异并调整应用程序。

在草案版本中,后端通信将由以下“`Adapter`”模拟。

export interface ITodoItem {
    id: number;
    title: string;
    complete: boolean;
}
const data = [{
}];
let index = data.length + 2;

class TodosAdapter {

    fetchTodos() {
        return new Promise<ITodoItem[]>((resolve) => {
            setTimeout(() => {
                const result = 
                   [...data.sort((l, r) => l.id > r.id ? -1 : l.id < r.id ? 1 : 0)];
                utils.text(utils.el('.console'), JSON.stringify(result, null, 2));
                resolve(result);
            }, 200);
        });
    }

    createTodo(title) {
        return new Promise<boolean>((resolve) => {
            setTimeout(() => {
                data.push({
                    id: index++,
                    title: title,
                    complete: false
                });
                resolve(true);
            }, 200);
        });
    }

    updateTodo(id, attrs) {
        return new Promise<boolean>((resolve, reject) => {
            setTimeout(() => {
                const item = utils.find(data, i => i.id === id);
                if (item) {
                    Object.assign(item, attrs);
                    resolve(true);
                } else {
                    reject(new Error
                          (`Can't update. "todo" task with id: ${id} was not found`));
                }
            });
        });
    }

    deleteTodo(id) {
        return new Promise<boolean>((resolve, reject) => {
            setTimeout(() => {
                const item = utils.find(data, i => i.id === id);
                const index = data.indexOf(item);
                if (item) {
                    data.splice(index, 1);
                    resolve(true);
                } else {
                    reject(new Error
                          (`Can't delete. "todo" task with id: ${id} was not found`))
                }
            });
        });
    }
}

适配器编写得好不好无关紧要。它可以是一个定义良好的解决方案,包含通用的 `fetch` 和 `convert` 方法,也可以是仅仅复制粘贴的数据传输代码块。重要的是 `public` 方法、它们的签名以及它们提供​​的结果。

IoC - 控制反转

在 MVVM 中,许多视图可以绑定到一个 ViewModel。这听起来像单例设计模式——`current(inst)`。一种有用的方法是将视图从视图模型中分离出来。在这里,IoC 设置可能是一个很好的解决方案。它将允许我们将视图和视图模型之间的所有组合保存在一个地方。IoC 设置可能会在未来发展。尽管如此,看着它内部会很可怕。然而,它只会是一个地方,而不是分散在许多智能视图之间。

小型 IoC

MainView.prototype.getViewModel = function () {
    return current(MainViewModel);
} 

IoC 设置还有更多候选者。它是 `TodosModel` 和 `Adapter` 之间的关系。`TodosModel` 和 `TodoViewModelItem` 之间的关系。`TodosModel` 和 `TodoMainViewModel`。

ViewModels 和可能 Views 的基类

预期视图、模型和视图模型之间会积极通信,让我们考虑一个中心点。有一个所有这些家伙都将继承的 `Base` 类将很方便。从那里,可以为所有参与者提供更多功能,而无需更新每个类。通过这种方法,将有一个用于对象内部状态的默认公共访问器。尽管事件传递入口点——`on` 和 `trigger` 方法。在现实世界中,可能已经定义了一个 `Base` 类。这就是为什么我将 `on` 和 `trigger` 方法与基本实现分开。关于基类,它是一个可调整的解决方案。而且由于它是基类,最好以清晰的方式列出事件并易于定位。对象构造函数可以达到这个目的。我将从父类构造函数中初始化所有事件。有一个 `prop('<propName>', <value>)` 方法。为了不产生许多可能不需要的 getter/setter 方法,最好有一个用于内部对象状态的默认公共访问器。

像这样——在子对象构造函数中声明事件

constructor() {
    super(
        'change:title',
        'change:items'
    );
    ...
}

下面是 `Base` 类的最终实现

function dispatcher() {

    const handlers = [];

    return {
        add(handler) {
            if (!handler) {
                throw new Error('Can\'t attach to empty handler');
            }
            handlers.push(handler);

            return function () {
                const index = handlers.indexOf(handler);
                if (~index) {
                    return handlers.splice(index, 1);
                }
                throw new Error('Ohm! Something went wrong with 
                                 detaching unexisting event handler');
            };
        },

        notify() {
            const args = [].slice.call(arguments, 0);
            for (const handler of handlers) {
                handler.apply(null, args);
            }
        }
    }
}

function initEvents(...args) {
    const events = {};
    for (const key of args) {
        events[key] = dispatcher();
    }
    return {
        on(eventName, handler) {
            return events[eventName].add(handler);
        },
        trigger(eventName) {
            events[eventName].notify();
        }
    };
}

class Base<S = {}> {
    state: S;

    constructor(...args: string[]) {
        const events = initEvents(...args);

        this.on = events.on;
        this.trigger = events.trigger;
    }

    on(eventName, handler) {
        throw new Error('Not implemented');
    }

    trigger(eventName) {
        throw new Error('Not implemented');
    }

    prop<K extends keyof S>(propName: K, val?: S[K]): S[K] {
        if (arguments.length > 1 && val !== (this.state as any)[propName]) {
            (this.state as any)[propName] = val;
            this.trigger('change:' + propName);
        }

        return this.state[propName];
    }
}

闪亮的(实用)代码

我们不写代码,只是思考一下库的形态、方法和它们的签名。为了让生活更轻松,解决方案将基于不进行转换的 HTML 模板。`html(html?)` - 一个有助于将 HTML 从模板应用到 DOM 元素的工具。HTML 渲染后,应该有一个工具来从 DOM 中选择元素——像这样:`el('.selector', <document/element>)`。由于它是一个待办事项列表,列表将包含项目。将有一个任务来更改列表项元素的属性和文本:`attr('<name>', <value>)` 和 `text(<el>, '<text>')`。总的来说,它需要从待办事项列表中删除、添加项目。很有可能有一个 `remove(<element>)`。解决方案将包含数据绑定,这意味着 HTML 不会被重绘,列表项将**刷新**。列表中的所有项目将在列表项更改时更新其数据。每个待办事项任务都将具有状态:_active_ 和 _complete_。这将是一个很好的补充,看看 MVVM 如何应对按活动/已完成任务进行过滤。为了进行这种智能操作,应该有更多工具:`find(<items>, <fn>)`、`filer(<items>, <fn>)`、`map(<items>, <fn>)`、`last(<items>, <from>)`、`first(<items>, <n>)`。最后但同样重要的是,监听 DOM 事件:`on`、`trigger`。

这是“`utils`”库的示例

const instances = new WeakMap();

export function current<T extends {}, O extends {}>(
    ctor: { new (...args): T },
    options?: O
): T {
    if (instances.has(ctor)) {
        return instances.get(ctor);
    }
    const inst = new ctor(options);
    instances.set(ctor, inst);

    return inst;
}

export function html(el, html?: string) {
    if (arguments.length > 1) {
        el.innerHTML = html;
    }
    return el.innerHTML;
}

export function el(selector, inst?) {
    inst = inst || document;
    if (!selector) {
        return null;
    }
    if ('string' === typeof selector) {
        return inst.querySelector(selector);
    }
    return selector;
}

export function attr(el, name, val?) {
    if (arguments.length > 2) {
        el.setAttribute(name, val);
    }
    return el.getAttribute(name);
}

export function text(el, text?) {
    if (arguments.length > 1) {
        el.innerText = text;
    }
    return el.innerText;
}

export function remove(el) {
    el.parentNode.removeChild(el);
}

export function on(inst, selector, eventName, fn) {
    const handler = function (evnt) {
        if (evnt.target.matches(selector)) {
            fn(evnt);
        }
    }
    inst.addEventListener(eventName, handler);
    return function () {
        inst.removeEventListener(eventName, handler);
    }
}

export function trigger(el, eventName) {
    el.dispatchEvent(new Event(eventName, { bubbles: true }));
}

export function getResult(inst, getFn) {
    const fnOrAny = getFn && getFn();
    if (typeof fnOrAny === 'function') {
        return fnOrAny.call(inst);
    }
    return fnOrAny;
}

export function find<T>(items: T[], fn: (item: T) => boolean) {
    for (const item of items) {
        if (fn(item)) {
            return item;
        }
    }
    return null;
}

export function filter<T>(items: T[], fn: (item: T) => boolean): T[] {
    const res = [] as T[]
    for (const item of items) {
        if (fn(item)) {
            res.push(item);
        }
    }
    return res;
}

export function map<T, Y>(items: T[], fn: (item: T) => Y): Y[] {
    const res = [] as Y[];
    for (const item of items) {
        res.push(fn(item));
    }
    return res;
}

export function last<T>(items: T[], from = 1): T[] {
    const length = items.length;
    return [].slice.call(items, from, length);
}

export function first<t>(items: T[], n = 1) {
    return [].slice.call(items, 0, n);
}

库的形状已调整。

MVVM 的三大支柱

现在我们打开潘多拉的盒子……

TodoModel(模型)将反映待办事项列表的行为。为此,应该有一种方法来保存待办事项任务,从标题创建新的待办事项,更新待办事项和删除待办事项。模型将通过适配器层向后端请求和提供 POKO 对象。它将通知参与者状态已更改。将所有从后端获取的数据保存在内部状态中。它将只保留接收到的数据。然后数据将通过事件传递给 ViewModel。它应该足够简单,以免弄乱每个项目。对于生产版本,它应该处理分页数据、OAuth 访问令牌,可能部分更新状态并通知特定项目更改。

一个棘手的问题是,_模型是否应该在需要时决定更新其状态(获取数据),还是允许 ViewModel 决定何时需要?_ 让我们假设当需要时,`this.fetch()` 将在模型内部调用,例如,当调用 `createTodo` 方法时。后端状态被改变了。添加了一个项目。调用 `this.fetch()` 将是合乎逻辑的。它将确保数据是最新的,并且模型将通知状态更改。这很方便,但也是一个限制。明智的做法是再三考虑。它可能导致对后端发出意外的多个请求。例如,在此解决方案中,`updateTodo` 将从两个地方调用。第一个地方是当更改待办事项任务的标题时。`updateTodo` 将只调用一次。从模型中调用 `this.fetch()` 是安全的。第二个地方是当将所有项目标记为完成时。这是一种批量更新。`updateTodo` 将对每个项目进行评估。这可能导致多次调用 `this.fetch()` 方法。想到它会向后端发出与更新项目数量一样多的获取请求,这不是一个愉快的想法。现在,让我们假设模型限制较少,只通知内部状态更改。预计 `this.fetch()` 的调用将在模型外部真正需要时触发。然后,它将从一个方面解决批量更新的问题。从另一方面,它将始终要求在最后提供每一个改变后端状态的方法来调用 `this.fetch()` 方法。我喜欢限制较少,但是,让我们保持被动行为,让 ViewModels 做出决定。

`ToDo` 模型的实现

class TodosModel extends Base {
    static inst = null as TodosModel;
    static instance() {
        if (TodosModel.inst === null) {
            TodosModel.inst = new TodosModel();
            TodosModel.inst.fetch();
        }

        return TodosModel.inst;
    }
    adapter = new TodosAdapter();
    items = [] as ITodoItem[];

    constructor() {
        super('change:items');
    }

    getItems() {
        return this.items;
    }

    setItems(val) {
        if (this.items !== val) {
            this.items = val;
            this.trigger('change:items');
        }
    }

    async fetch() {
        const items = await this.adapter.fetchTodos();
        this.setItems(items);
    }

    createTodo(title) {
        return this.adapter.createTodo(title);
    }

    updateTodo(item: ITodoItem) {
        const { id, ...attrs } = item;
        return this.adapter.updateTodo(id, attrs);
    }

    deleteTodo(item: ITodoItem) {
        const { id } = item;
        return this.adapter.deleteTodo(id);
    }
}

对于那些希望采用更严格方式的人,有一个解决方案。对于应用程序中的小模块,将 `this.fetch()` 方法修改为防抖动方法。对于中型模块,引入批量项目 `update` 方法。这将解决批量更新问题,并且仍然只会调用一次,例如,`this.fetch = _.debounce(this.fetch, 200);`。对于大型模块,这种限制可能会损害网络性能,最好让模型保持被动获取行为。这将避免一些不必要的额外后端请求。代价是 ViewModel 将包含更多代码。顺便说一句,实现批量更新也会导致更多代码。它将使实现更清晰。

视图模型

解决方案中有两个视图模型。第一个是 `MainViewModel`,用于主视图。第二个用于每个 `todo` 任务——`TodoViewModelItem`。主视图模型以方便的方式保存数据,以便在主界面中显示。`todo` 视图模型保存要列出的项目数据。视图模型可以分为两种:视图模型和视图模型项。这两种模型的主要区别在于它们的创建和生命周期。主视图模型只会创建一次。它将对所有视图可用。`todo` 视图模型项可以创建多次。它的设计理念是易于从内存中删除。它主要负责将值转换为在 UI 上呈现的逻辑。

项目视图模型可以分为四个部分。视图模型命令、构造函数、getter 部分、命令实现部分。由于它将很快从内存中丢弃,因此没有数据绑定命令。它引用 `TodoModel` 单例并调用更新后端状态逻辑。请注意,它将模型中的 POKO 项对象作为初始参数。它没有 setter。它可以简单地从内存中丢弃,没有内存泄漏。

待办事项视图模型项目示例

class TodoViewModelItem {
    completeCommand = { exec: isComplete => this.complete(isComplete) };
    deleteCommand = { exec: () => this.remove() };
    updateTitleCommand = { exec: title => this.updateTitle(title)};

    constructor(public item: ITodoItem) {
    
    }

    getId() {
        return this.item.id;
    }

    getTitle() {
        return this.item.title;
    }

    getIsComplete() {
        return this.item.complete;
    }

    async updateTitle(title) {
        const todosModel = TodosModel.instance();
        await todosModel.updateTodo({
            ...this.item,
            title: title
        });
        todosModel.fetch();
    }

    async complete(isComplete) {
        const todosModel = TodosModel.instance();
        await todosModel.updateTodo({
            ...this.item,
            complete: isComplete
        });
        todosModel.fetch();
    }

    async remove() {
        const todosModel = TodosModel.instance();
        await todosModel.deleteTodo(this.item);
        todosModel.fetch();
    }
}

一般来说,主视图模型比 `todo` 视图模型项更智能。它在 `Base` 类中实现了 getter/setter。它是 `prop('<propName>', <propValue>)`。它可以分为五个部分。新状态部分、视图模型命令、构造函数、getter/setter 和命令实现部分。由于它将被大量使用,它应该包含一种从模型附加/分离监听器的方法。创建和销毁主视图模型可能是一项复杂的任务。并且期望在创建时附加事件监听器并在销毁时分离事件监听器。它有一个空构造函数,所有初始化逻辑都提取到 `initialize` 方法中。这是可调整的,`initialize` 方法可以从构造函数或从父创建例程中调用。这取决于特定的实现。

主视图模型将把要在 UI 上显示的 `todo` 任务填充到其内部状态中。然后它将通知所有订阅的参与者状态更改。该逻辑隐藏在基类中。在主视图模型中,它通过调用 `this.prop('<propName>', <propValue>)` 进行调整。

class MainViewModel extends Base<MainViewModel['state']> {
    state = {
        title: '',
        items: [] as TodoViewModelItem[]
    }

    createNewItemCommand = { exec: () => this.createNewItem() };
    toggleAllCompleteCommand = {
        canExecute: () => !this.areAllComplete(),
        exec: () => this.toggleAllCompleteCommand.canExecute() && this.markAllComplete()
    };
    offChangeItems;

    constructor() {
        super(
            'change:title',
            'change:items'
        );
        this.initialize();
    }

    initialize() {
        const todos = TodosModel.instance();
        this.offChangeItems = todos.on('change:items', () => {
            this.populateItems();
        });
    }

    populateItems() {
        const todos = TodosModel.instance();
        this.prop('items', utils.map(todos.getItems(), item => new TodoViewModelItem(item)));
    }

    async createNewItem() {
        const model = TodosModel.instance();
        await model.createTodo(this.prop('title'));
        model.fetch();
        this.prop('title', '');
    }

    markAllComplete() {
        utils.map(this.prop('items'), m => m.complete(true));
    }
    
    areAllComplete() {
        if (!this.prop('items').length) {
            return false;
        }
        return !utils.find(this.prop('items'), i => !i.getIsComplete());
    }
}

总结:视图模型可以分为两种类型。重型——可以有事件监听器,包含与实现需求相关的复杂逻辑,实例构造复杂。它多次创建轻型视图模型。轻型——最小化地实现需求,可以多次创建和从内存中丢弃。可以有用于 UI 展示的数据转换。

UI 层 - 视图

视图也可以分为几种类型。纯视图(控件)、根视图和项目视图。纯视图虽然也称为控件。它们只负责绘制 UI,保留子视图。并且从不绑定到视图模型。它将与其余视图分开放在一个单独的文件夹中。

一个完美的例子是 `ListView`

class ListView<T extends IListItemView<VM>, 
      VM = ExtractViewModel<T>> extends Base<ListView<T, VM>['state']> {
    options = this.initOptions(this.config);
    el = utils.el(this.options.el);
    state = {
        items: [] as ExtractViewModel<T>[],
        children: [] as T[]
    };
    filter = null;
    offChangeItems;
    offChangeFilter;

    constructor(public config: ReturnType<ListView<T, VM>['initOptions']>) {
        super(
            'change:items',
            'change:children',
            'change:filter'
        );
        this.initialize();
    }

    getFilter() {
        return this.filter;
    }

    setFilter(fn: (i: ExtractViewModel<T>) => boolean) {
        if (this.filter !== fn) {
            this.filter = fn;
            this.trigger('change:filter');
        }
    }

    initOptions(options = {}) {
        const defOpts = {
            el: '',
            createItem(props?): T {
                return null;
            }
        };
    
        return {
            ...defOpts,
            ...options
        };
    }

    initialize() {
        this.offChangeItems = this.on('change:items', () => this.drawItems());
        this.offChangeFilter = this.on('change:filter', () => this.drawItems());
    }

    drawItem(viewModel: ExtractViewModel<T>, index: number) {
        const itemViews = this.prop('children');
        const currentView = itemViews[index];
        const itemView = currentView || this.options.createItem();
        if (!currentView) {
            this.prop('children', [...this.prop('children'), itemView]);
            this.el.append(itemView.el);
        }
        itemView.setViewModel(viewModel);
    }

    drawItems() {
        const items = this.filter ? 
              utils.filter(this.prop('items'), this.filter) : this.prop('items'),
            length = items.length,
            firstChildren = utils.first(this.prop('children'), length),
            restChildren = utils.last(this.prop('children'), length);

        this.prop('children', firstChildren);
        for (const itemView of restChildren) {
            itemView.remove();
        }

        for (let i = 0; i < length; i++) {
            const model = items[i];
            this.drawItem(model, i);
        }
    }

    remove() {
        utils.getResult(this, () => this.offChangeItems);
        utils.getResult(this, () => this.offChangeFilter);
        utils.remove(this.el);
    }
}

列表视图仅负责创建、销毁和渲染子视图。它永远不会直接与视图模型通信。它可以通过父视图的数据绑定命令绑定到视图模型。永远不会在其中包含数据绑定命令。作为一个自给自足的视图。`ListView` 负责在 UI 上绘制列表项。它可以通过 `setFilter` setter 设置特殊的过滤函数来过滤项目。在这个例子中,`ListView` 以特殊的方式设计。它将用新数据刷新当前渲染的子视图。这里的想法是最小化地操作 DOM 元素。`todo` UI 有一个输入框来编辑 `todo` 任务标题。在这种情况下,如果 DOM 被删除然后插入(重绘),当前正在编辑的输入(焦点)将失去焦点。这将影响 UI 体验。幸运的是,有数据绑定。每个字段都绑定到其源。我们可以利用这一点。在视图中切换视图模型将导致填充已更改的值,而不会替换相同的值。

请查看 `TodoListItemView` —— `setViewModel` 方法

function htmlToEl(html) {
    const el = document.createElement('div');
    utils.html(el, html);

    return el.firstElementChild;
}

class TodoListItemView<T extends TodoViewModelItem> {
    el = htmlToEl(template({}));
    vm: T;
    completeCommand;
    deleteCommand;
    updateTitleCommand;
    offTitleChange;
    offCompletedChange;
    offDeleteClick;

    getId() {
        return utils.attr(this.el, 'data-id');
    }

    setId(val) {
        if (this.getId() !== val) {
            utils.attr(this.el, 'data-id', val);
        }
        return val;
    }

    getTitle() {
        return utils.el('.title', this.el).value;
    }

    setTitle(val) {
        if (this.getTitle() !== val) {
            const title = utils.el('.title', this.el);
            title.value = val;
        }
        return val;
    }

    getCompleted() {
        const el = utils.el('.completed', this.el);
        return el.checked;
    }

    setCompleted(newValue) {
        const oldValue = this.getCompleted();
        if (oldValue !== newValue) {
            utils.el('.completed', this.el).checked = newValue;
        }
        return newValue;
    }

    bind() {
        this.unbind();
        this.completeCommand = this.vm.completeCommand;
        this.deleteCommand = this.vm.deleteCommand;
        this.updateTitleCommand = this.vm.updateTitleCommand;
        this.offCompletedChange = utils.on(this.el, '.completed', 
                'click', () => this.completeCommand.exec(this.getCompleted()));
        this.offDeleteClick = utils.on(this.el, '.delete', 
                'click', () => this.deleteCommand.exec());
        this.offTitleChange = utils.on(this.el, '.title', 
                'input', () => this.updateTitleCommand.exec(this.getTitle()));
    }

    unbind() {
        this.completeCommand = null;
        this.updateTitleCommand = null;
        this.deleteCommand = null;
        utils.getResult(this, () => this.offTitleChange);
        utils.getResult(this, () => this.offCompletedChange);
        utils.getResult(this, () => this.offDeleteClick);
    }

    setViewModel(item: T) {
        if (this.vm !== item) {
            this.vm = item;
            this.bind();
            this.setId(item.getId());
            this.setTitle(item.getTitle());
            this.setCompleted(item.getIsComplete());
        }
    }

    remove() {
        this.unbind();
        utils.remove(this.el);
    }
}

`setViewModel` 方法是一个特殊的 getter。我甚至不能称之为 getter。它是一个设置视图模型的方法。它将负责以正确的方式分配视图模型实例。在示例中,它缺少 `this.unbind` 调用。这是因为它在没有分配视图模型的情况下从未使用过。但预期 todo 项视图将被删除。`remove` 方法在其实现中包含 `this.unbind()` 调用。它通过相关的 setter 将视图模型中的值分配给 UI。

`todo` 项视图的设计方式是将其状态保存在 DOM 元素上。这反映在 getter/setter 方法中。setter 方法以特殊方式设计。在设置值之前,它们会单独检查更改。这是一个很好的规则,用于检查旧值是否与新值相同。这将避免不必要的 UI 刷新并改善在文本字段中输入时的 UI 体验(重置光标位置)。

在清晰的设计中,遵循 MVVM 的主要原因之一是因为绑定命令被注入到模板引擎中。由于该解决方案缺少这样的引擎,数据绑定命令被分组为一对 `unbind` / `bind` 方法。理想情况下,`bind` / `unbind` 方法应该在 HTML 渲染例程之前和之后调用。我在这里作弊了。由于 HTML 渲染只在实例初始化期间发生一次,并且视图模型会更频繁地更改,因此在每次视图模型更改后都会调用 unbind / bind 方法。从技术上讲,它仍然没有违反规则——在渲染 HTML 后调用。甚至更像是一个复杂绑定命令的手动实现,例如,`'title': 'vm.prop('title')'`。这只是本文 MVVM 解释中的一个大陷阱。

接下来是 `MainView`

interface MainView extends ReturnType<typeof initialize$MainView> {

}

function initialize$MainView<T>(inst: T, el) {
    return Object.assign(inst, {
        newTitle: utils.el('.new-title', el),
        allComplete: utils.el('.complete-all', el),
        filterAll: utils.el('.all', el),
        filterActive: utils.el('.active', el),
        filterCompleted: utils.el('.completed', el),
        todoList: new ListView({
            el: utils.el('.todo-list', el),
            createItem() {
                return new TodoListItemView<TodoViewModelItem>();
            }
        })
    });
}

class MainView extends Base {
    vm = this.getViewModel();
    options = this.initOptions(this.config);
    el = utils.el(this.options.el);
    offTitleToModel;
    offTitleFromModel;
    offItemsFromModel;

    offOnKeypress;
    offMarkAllComplete;
    offChangeItems;
    offFilter;

    createNewItemCommand = { exec() { return; } };
    toggleAllCompleteCommand = {
        canExecute() { return false; },
        exec() { return; }
    };

    constructor(public config: ReturnType<MainView['initOptions']>) {
        super('change:items');
    }

    getTitle() {
        return this.newTitle.value;
    }

    setTitle(val) {
        if (this.newTitle.value !== val) {
            this.newTitle.value = val;
            utils.trigger(this.newTitle, 'input');
        }
    }

    getAllComplete() {
        return this.allComplete.checked;
    }

    setAllComplete(val) {
        if (this.allComplete.checked !== val) {
            this.allComplete.checked = val;
            utils.trigger(this.allComplete, 'change');
        }
    }

    getFilter() {
        const el = utils.el('.filter input:checked', this.el);
        return el && el.value;
    }

    setFilter(newValue: 'all' | 'active' | 'completed') {
        const oldValue = this.getFilter();
        if (newValue !== oldValue) {
            const el = utils.el(`.filter input[value="${newValue}"]`);
            el.checked = true;
        }
    }

    getViewModel() { 
        return null as MainViewModel;
    }

    setViewModel() {
        this.unbind();
        this.setFilter('all');
        this.setAllComplete(this.toggleAllCompleteCommand.canExecute());
        this.bind();
    }

    initOptions(options = {}) {
        const defOpts = {
            el: 'body'
        };
        return {
            ...defOpts,
            ...options
        };
    }

    initialize() {
        const html = template({
            vid: 1
        });
        utils.html(this.el, html);
        initialize$MainView(this, this.el);

        this.offOnKeypress = utils.on(this.el, '.new-title', 
                             'keypress', evnt => this.onKeypress(evnt));
        this.offMarkAllComplete = utils.on(this.el, '.complete-all', 
                                  'change', () => this.getAllComplete() && 
                                  this.toggleAllCompleteCommand.exec());
        this.offChangeItems = this.todoList.on('change:items', 
                              () => this.setAllComplete
                              (!this.toggleAllCompleteCommand.canExecute()));
        this.offFilter = utils.on(this.el, '.filter input', 'click', 
                         () => this.filterItems(this.getFilter()));

        this.setViewModel();
    }

    bind() {
        this.unbind();
        this.createNewItemCommand = this.vm.createNewItemCommand;
        this.toggleAllCompleteCommand = this.vm.toggleAllCompleteCommand;
        this.offTitleToModel = utils.on(this.el, '.new-title', 
                               'input', () => this.vm.prop('title', this.getTitle()));
        this.offTitleFromModel = this.vm.on('change:title', 
                                 () => this.setTitle(this.vm.prop('title')));
        this.offItemsFromModel = this.vm.on('change:items', 
                                 () => this.todoList.prop('items', this.vm.prop('items')));
    }

    unbind() {
        this.createNewItemCommand = { exec() { return; } };
        this.toggleAllCompleteCommand = { canExecute() { return false; }, exec() { return; } };
        utils.getResult(this, () => this.offTitleToModel);
        utils.getResult(this, () => this.offTitleFromModel);
        utils.getResult(this, () => this.offItemsFromModel);
    }

    onKeypress(evnt) {
        if (evnt.which === ENTER_KEY && ('' + this.newTitle.value).trim()) {
            this.createNewItemCommand.exec();
        }
    }

    filterItems(filterName: 'all' | 'active' | 'completed') {
        switch (filterName) {
            case 'active':
                return this.todoList.setFilter(i => !i.getIsComplete());
            case 'completed':
                return this.todoList.setFilter(i => i.getIsComplete());
            default:
                return this.todoList.setFilter(null);
        }
    }

    remove() {
        utils.getResult(this, () => this.offOnKeypress);
        utils.getResult(this, () => this.offMarkAllComplete);
        utils.getResult(this, () => this.offChangeItems);
        utils.getResult(this, () => this.offFilter);

        utils.remove(this.el);
    }
}

`MainView` 是一个重量级的根视图,包含需求实现。它在 `unbind/bind` 方法中绑定到 `MainViewModel`。`TodoListItemView` 与 `MainView` 的主要区别在于 DOM 事件监听器和数据绑定命令的定义。因为根视图的初始化是构造函数的一个复杂部分,所以它被提取到一个单独的 `initialize` 方法中。预期在实例化 `MainView` 类后调用 `initialize` 方法。

类似这样。`App.run` 入口点调用 `main.initialize();`

const template = data => `<div class="application">Loading...</div>`;

class App {
    static run() {
        utils.html(document.body, template({}));
        const main = new MainView({
            el: utils.el('.application')
        });
        main.initialize();
    }
}

setTimeout(() => {
    App.run();
});

`MainView`、`TodoListItemView` 和 `ListView` 仅包含与 UI 相关的逻辑。它们不包含任何数据操作。所有关于数据操作(`create` / `read` / `update` / `delete`)都整合在视图模型中。

DOM 元素属性的初始化被提取到一个单独的 `initialize$MainView` 方法中。当进行下一次开发迭代时,HTML 模板中的文本与重命名的元素或 CSS 类名一起更改时——`initialize$MainView` 是我唯一可以查找和调整这些更改的地方。DOM 事件监听器也分组在那里。由于它们是 UI 的一部分,因此没有必要对其进行 `unbind` / `bind` 操作。它们将与 `MainView` 实例的存在时间一样长。然后调用 `this.setViewModel();` 方法来分配视图模型。`unbind` / `bind` 方法旨在保留与将视图绑定到视图模型相关的命令。

命令 - MVVM 设计中的瑰宝

有一些东西介于视图和视图模型之间。这些小段代码既可以是它们的一部分,又可以分离。MVVM 命令。任何从 UI 发出的信号,与更新数据相关的,从 UI 指向视图模型的,都可以视为命令。应用程序的特殊部分,帮助以预期的使用方式使用应用程序。始终由客户、QA 测试人员、开发人员审查,以便找到任何可能隐藏在 UI 上简单鼠标点击(或触摸)后面的复杂逻辑的入口点。它们让人想起计算机进化早期平均用户流行的命令行控制台的黑色屏幕带来的孤独感。现在只有开发人员和系统管理员生活在那里。

我将列出所有内容。让他们自己说话。

createNewItemCommand = { exec: () => this.createNewItem() };
deleteCommand = { exec: () => this.remove() };
updateTitleCommand = { exec: title => this.updateTitle(title)};
completeCommand = { exec: isComplete => this.complete(isComplete) };
toggleAllCompleteCommand = {
    canExecute: () => !this.areAllComplete(),
    exec: () => this.toggleAllCompleteCommand.canExecute() && this.markAllComplete()
};

潜在的过滤项也可能在命令列表中。目前,通过“全部”、“活动”和“已完成”进行过滤的 UI 操作只是 UI 的一部分,因为它过滤的是已从后端获取的项。如果后端能够提供过滤请求,它们也将成为该列表的一部分。

献给寻求更多答案的人

总的来说,我亲爱的读者已经注意到一些奇怪的命令。如下所示

utils.getResult(this, () => this.offMarkAllComplete);
utils.getResult(this, () => this.offChangeItems);
utils.getResult(this, () => this.offFilter);

在 Backbone JS 中,有一个命令 `_.result(object, '<propName')`。它通过提供的名称从对象的方法中提取值。我决定使用类似的东西。`utils.getResults` 用于相同的情况。

`initialize$MainView` 方法可以是 `MainView` 的一部分。将其从类中提取的主要原因是,通过这种方式,我可以同时定义自定义属性并在 `MainView` 类上声明它们。

项目的文件结构

.
 |-viewModels
 | |-mainViewModel.ts
 | |-index.ts
 | |-todoViewModelItem.ts
 |-utils
 | |-index.ts
 |-models
 | |-todos.ts
 | |-index.ts
 |-controls
 | |-listView.ts
 |-adapters
 | |-todos.ts
 |-templates
 | |-mainView.ts
 | |-listItem.ts
 |-index.ts
 |-views
 | |-todoListItemView.ts
 | |-index.ts
 | |-mainView.ts
 |-base
 | |-base.ts

关于 MVVM 设计模式的结论

MVVM 设计模式在熟悉 MVVM 结构的所有部分的情况下很容易遵循。感觉我只是专注于解决和实现需求,而不是与语言构造作斗争,以找到正确的解决方案的合适位置。最好的部分是能够在不修改核心实现的情况下重新编程 UI。尽管如此,它可以根据特定客户的需求进行调整。很容易找到应用程序的每个部分并开始实现新需求。

由于 JavaScript 语言缺乏文档,第一次尝试解决和连接所有 MVVM 思想是很困难的。我自己也深入研究了许多来自 C# WPF.NET 的资源。最令人困惑的部分是解决数据绑定概念的实现。尽管理解简单,但很难解耦参与者以及绑定代码和视图与模型代码。如果有一天出现具有数据绑定语句含义的模板语言,那将是非常棒的。请告诉我,如果我错过了任何可以成为本文一部分的 MVVM 精彩想法。

优点

  • 设置 UI 很容易,更新/刷新次数更少,因为 UI 的每个部分都绑定到数据的特定部分。
  • 它可以在开发的任何阶段引入应用程序。
  • 不要求框架或实用程序库。
  • 凭借丰富的工具和清晰的方法,将具有灵活性,限制将作为辅助工具之一。
  • 它提供了更多实现业务需求的地方。
  • 在小型和中型应用程序中很容易实现。
  • 视图层可以以较少的精力替换。

后果

  • 由于 UI 绑定到数据,因此可能很难确定模块的哪个部分负责 UI 上的哪个刷新位置。
  • 这导致了更多的代码编写。
  • 弱小的工具和不清晰的方法可能比灵活更具限制性。
  • 薄弱的实施可能导致混淆,并减少对 MVVM 层任务的理解。
  • 在大应用中实现将需要很大的努力。
  • 对于某些特定的框架库来说,实现可能会有问题。

我把这篇文章献给我的妻子、家人和亲戚们。

因为,我花费了许多个周末来撰写这篇文章的材料,而不是把我的注意力放在最需要它的人身上。

寻找灵感的文章

历史

  • 2020 年 4 月 23 日:初始版本
© . All rights reserved.