iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 26
0
Software Development

自己用的工具自己做! 30天玩轉VS Code Extension之旅系列 第 26

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上,並於之後提供連結給讀者參考。


上一篇
Day25 | Extension事件與資料處理
下一篇
Day27 | 導入WebviewPanel
系列文
自己用的工具自己做! 30天玩轉VS Code Extension之旅36

尚未有邦友留言

立即登入留言