Day26 | 实现Extension内的MVVM架构

大家好,我是韦恩,今天是铁人赛的二十六天,让我们来设计extension中的MVVM架构吧!


MVVM软件架构简介与Extension中的实作


在软件设计中,MVVM(Model–view–viewmodel)是一种通用且流行的软件架构模式,在各个主流前端框架,如vuejs、react、angular中,都可以见到MVVM的影子。


[图片来源:维基百科]

MVVM架构将应用程序分为三大部分:

  1. Model: 真正被储存的数据格式模型,以我们的专案应用程序为例,我们的workspace实际上储存的格式是按照vscode规范的snippet格式。在ts里,我们会这样定义snippet的资料模型:
export interface SnippetJsonObject {
 [title: string]: Omit<SnippetSetting, 'title'>;
}

export interface SnippetSetting {
 title: string;
 prefix: string;
 body: string[];
 description: string;
}
  1. ViewModel: 在提供给View展示数据前,我们的extenions会在前端将原始的model数据格式转换。转变成利於View元件转换并展示的格式,在viewModel中我们也会储存转换格式後的资料。在我们的extension里,ViewModel角色主要由昨天设计的Workspace类别担当,我们的ViewModel的数据格式如下:
export interface SnippetStorageItem {
 category: string; 
 settings: SnippetSetting[];
};

export type SnippetStorage = SnippetStorageItem[];

同时,我们也会在Workspace中储存转换後的资料。

export class Workspace {
 ...
 public readonly path: string | undefined;

 private _storage : SnippetStorage | undefined;
 
 public get storage() {
  return this._storage;
 }
 ...
}

当资料载入时,我们会将资料显示在介面上,也即是接下来我们要提的View的部分。

  1. View: 用户在使用者介面上看到的UI,我们会使用声明式的方式让资料显示於介面,也就是说,我们无须实现UI的细节,仅需改变ViewModel也是Workspace中的snippet资料,改动即会透过事件通知TreeView将改动显示於VSCode上。

在我们的extension中,View的部分主要是TreeView与WebView。

TreeView的DataProvider经过调整後会是底下这样,因为我们的Extension只会有一个TreeView,我们也随之在TreeView的DataProvider中使用singleton的模式。

export class TreeDataProvider implements vscode.TreeDataProvider<TreeViewItem> {

	private static instance: TreeDataProvider | undefined;
	
	public static getInsance(workspace: Workspace) {
		if(!TreeDataProvider.instance) {
			TreeDataProvider.instance = new TreeDataProvider(workspace);
		}
		return TreeDataProvider.instance;
	}
	
	private eventEmitter = new vscode.EventEmitter<TreeViewItem | undefined | void>();

	public get onDidChangeTreeData(): vscode.Event<TreeViewItem | undefined | void> {
		return this.eventEmitter.event;
	}

	private get treeItems(): TreeViewItem[] | undefined {
		return this.workspace.storage?.map(storageItem => new TreeViewItem(
			storageItem.category,
			storageItem.settings.map((setting) => this.snippetToItem(setting.title, setting, storageItem.category)))
		);
	}

	private constructor(
		private workspace: Workspace
	) { }

	private snippetToItem(title: string, snippet: SnippetSetting, category: string) {
		return new TreeViewItem(title).setSnippet(snippet).setCategory(category);
	}

	public getTreeItem(element: TreeViewItem): vscode.TreeItem | Thenable<vscode.TreeItem> {
		return element;
	}

	public getChildren(element: TreeViewItem): vscode.ProviderResult<TreeViewItem[]> {
		if(!element) {
			return this.treeItems || [];
		}
		return element.children;
	}

	public updateView() {
		this.eventEmitter.fire();
	}
}

读者们可以看到,上面我们并未处理UI的逻辑,仅是将ViewModel的资料转换为TreeView所需的TreeItem。

然後,我们会这样让View与ViewModel结合

export const initWorkspaceViewModel = (context: vscode.ExtensionContext) => {

    const workspace = new Workspace(context);

	workspace.onDidChangeData(EventType.LOAD, () => {
		registerTreeview(context, workspace);
	});

	workspace.onDidChangeData(EventType.CHANGE, () => {
		TreeDataProvider.getInsance(workspace).updateView();
    });
 
   loadWorkspaceData(workspace: Workspace)
  
   return workspace;
}; 

上面我们让TreeView的DataProvider在实例化时,就直接拿到workspace,并使用workspace的storage中的snippet资料。然後,我们会透过监听的事件,在ViewModel资料变化时通知TreeView时刷新UI的元件,完成View与ViewModel的Data Binding。

当使用者想要编辑或删除TreeView的时候,我们让使用者可以直接在树状元件上点击对应Icon触发command。

为此,我们在Contribution Point中先注册好UI与对应设定

{
    ...
	"contributes": {
		...
		"commands": [
			...
			{
				"command": "ithome30-code-manager.editSnippet",
				"title": "Code Manager: Edit Exist Item",
				"icon": {
					"light": "assets/edit.svg",
					"dark": "assets/edit.svg"
				}
			},
			{
				"command": "ithome30-code-manager.deleteSnippet",
				"title": "Code Manager: Delete Item",
				"icon": {
					"light": "assets/trash.svg",
					"dark": "assets/trash.svg"
				}
			}
		],
		"menus": {
			...
			"view/title": [
				{
					"command": "ithome30-code-manager.addSnippet",
					"when": "view == cmtreeview"
				}
			],
			"view/item/context": [
				{
					"command": "ithome30-code-manager.editSnippet",
					"when": "view == cmtreeview && viewItem == snippetItem",
					"group": "inline"
				},
				{
					"command": "ithome30-code-manager.deleteSnippet",
					"when": "view == cmtreeview && viewItem == snippetItem",
					"group": "inline"
				}
			]
		}
	},
    ...
} 

在点击编辑或删除的icon时,vscode会触发命令,这时候我们可以拿到对应的TreeViewItem并对ViewModel(Workspace)做出资料改动的操作。

底下是我们的命令处理实作:


export function registerTreeItemCommand(context: vscode.ExtensionContext, workspace: Workspace) {

	const editCommand = vscode.commands.registerCommand('ithome30-code-manager.editSnippet', async (item: TreeViewItem) => {
			if(item.children) {
				const newCategoryName = await input('Enter new ategory name', item.label || '');
				if(newCategoryName === '') return;
				workspace.editCategory(item.label!, newCategoryName);
			}
	});

	const deleteCommand = vscode.commands.registerCommand('ithome30-code-manager.deleteSnippet', async (item: TreeViewItem) => {
			const isCategory = !!item.children;
			const prompt = isCategory ? `Make sure to delete category: ${item.label}` : `Make sure to delete snippet: ${item.snippet?.title}`;
			const isConfirm = await confirm(prompt);
			if(!isConfirm) return;  
			if(isCategory) {
				workspace.deleteCategory(item.label);
			} else {
				workspace.deleteSnippet({ category: item.category!, setting: item.snippet!});
			}
	});

	context.subscriptions.push(editCommand, deleteCommand);
}

上面我们仅在使用者对介面操作时执行workspace.editCategoryworkspace.editCategory等方法,改变ViewModel的资料,ViewModel在改变资料後会通知TreeView刷新介面展示改动後的元件。

  • CodeManager Extension中Model与ViewModel沟通的实作

在上面,我们简单介绍了MVVM,并以TreeView跟Workspace的相关实作做范例。现在我们再提一些Model与ViewModel沟通的部分。

前段有提到,Model层是代表实际储存的数据模型,在我们的专案里,我们是用vscode的snippet格式储存相关设定在json档案里。现在我们要将model的资料转换并提供给ViewModel,也就是Workspace这个类别。

我们在一个snippetfile.ts定义读取snippet档案,并将其转换为ViewModel中储存的资料格式,如底下所示。

import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as jsonfile from 'jsonfile';
...
export const loadSnippets = (workspacePath: string): SnippetStorage => {
	const files = fs.readdirSync(workspacePath);
	return files?.map((foldername) => {
		if (fs.statSync(path.join(workspacePath, foldername)).isDirectory()) {
			return {
				category: foldername,
				settings: snippetSettings(
					jsonfile.readFileSync(path.join(workspacePath, foldername, `${foldername}.code-snippets`))
				)
			};
		}
		return {
			category: foldername,
			settings: snippetSettings(
				jsonfile.readFileSync(path.join(workspacePath, `global.code-snippets`))
			)
		};
	});
}

接着,我们提供loadWorkspace这个方法,让Workspace可以使用转换後的资料

export function loadWorkspaceData(workspace: Workspace) {
      if(!workspace.path) {
       vscode.window.showInformationMessage('Should create or choose a code-manager workspace first'!);
       return;
      }
      const storage = loadSnippets(workspace.path);
      workspace.loadSnippets(storage);
    }
}

在初始化Worksapce时,我们即让Workspace载入资料

export const initWorkspaceViewModel = (context: vscode.ExtensionContext) => {

 const workspace = new Workspace(context);

	workspace.onDidChangeData(EventType.LOAD, () => {
		registerTreeview(context, workspace);
	});
    
    ...
    
    loadWorkspacedata(workspace: Workspace)
  
    return workspace;
}; 

在Workspace资料载入後,Workspace会发射load事件,让TreeView初始化并展示Workspace的资料。

如此,我们即完成了读取snippet资料时Model->ViewModel->View的资料流。

反过来,当使用者对View进行相关操作後,ViewModel会改变,改变後即须通知Model层做对应的修改。

因此,我们在snippetfile.ts提供writeSnippetToFile与相关的删除方法,接着我们会观察监听workspace的事件,在ViewModel发生变化时,将改动时将ViewModel的数据格式转回Model层的数据模型,写入持久化储存的档案之中。

export function watchWorkspaceEvent(workspace: Workspace) {
    const workspace = new Workspace(context);
  
	workspace.onDidChangeData(EventType.ADD_CATEGORY, (item: SnippetStorageItem) => {
      if(!workspace.path) return;
      writeSnippet(workspace.path, item.category, item.settings);
    });

     workspace.onDidChangeData(EventType.EDIT_CATEGORY, (oldCategory: string, newCategory: string) => {
          if(!workspace.path) return;
		renameCategory(workspace.path, oldCategory, newCategory);
	});

	workspace.onDidChangeData(EventType.DELETE_CATEGORY, (category: string) => {
		if(!workspace.path) return;
		deleteCategory(workspace.path, category);
	});
    ...
}

接着,我们和上面绑定View一样在initWorkspace这个方法里进行ViewModel与Model绑定的动作。

...
import * as snippetModel from '../snippets/snippetfile';

export const initWorkspaceViewModel = (context: vscode.ExtensionContext) => {

 const workspace = new Workspace(context);
 
 ...

 snippetModel.watchWorkspaceEvent(workspace);

 ...

 return workspace;
}; 

在extension处於active状态时,我们即在extension的进入点active函式中执行initWorkspace方法,绑定我们的CodeManager里的Model-ViewModel-View各元件。


export function activate(context: vscode.ExtensionContext) {

	const workspace = initWorkspaceViewModel(context);

	registerTreeItemCommand(context, workspace);
 
    ...
}

结语


好啦,今天,我们介绍了MVVM架构,并了解在extenision中可以怎麽实现绑定Model-View-Model的逻辑。

明天我们将介绍CodeManager里是怎麽实作WebViewPanel的部分,了解如何将React的View层整合进我们Extension的MVVM架构里。

我们明天见,谢谢大家。

因为专案的程序码繁多,此处仅就重点介绍extension的架构与重要逻辑的程序码实现。全部的专案程序码笔者会放置於github的repo上,并於之後提供连结给读者参考。


<<:  第26天:this(1)

>>:  [Day26] 建立购物车系统 - 9

Day2-看看JDK内有些什麽好用的工具!

前言 工作了好一段时间後,直到那次处理了OOM(Out Of Memory)问题,才发现JDK内有很...

Day 4 [Python ML] 模型验证

前言 今天要继续昨天做过的部分,因此一开始需要昨天的程序码 import pandas as pd ...

卡夫卡的藏书阁【Book16】- Kafka - KafkaJS 生产者 - 4

“It's only because of their stupidity that they'r...

Day15 测试写起乃 - Devise login user

在开始写测试的时候因为许多 action 进入前都必须要先登入使用者才能有权限做其他事情,但在测试该...

DAY25: 作用域三种类

在这一篇主要讲了Node 在终机端和脚本文件this不同的指向,那麽今天要来简单介绍Nodejs作用...