大家好,我是韋恩,今天是鐵人賽的二十六天,讓我們來設計extension中的MVVM架構吧!
在軟體設計中,MVVM(Model–view–viewmodel)是一種通用且流行的軟體架構模式,在各個主流前端框架,如vuejs、react、angular中,都可以見到MVVM的影子。
[圖片來源:維基百科]
MVVM架構將應用程式分為三大部分:
export interface SnippetJsonObject {
[title: string]: Omit<SnippetSetting, 'title'>;
}
export interface SnippetSetting {
title: string;
prefix: string;
body: string[];
description: string;
}
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的部分。
在我們的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.editCategory
與workspace.editCategory
等方法,改變ViewModel的資料,ViewModel在改變資料後會通知TreeView刷新介面展示改動後的元件。
在上面,我們簡單介紹了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上,並於之後提供連結給讀者參考。