從零開始實現VS Code基金插件

寫在前面

隨着7月一波牛市行情,愈來愈多的人投身A股行列,可是股市的風險巨大,有人一晚上暴富,也有人血本無歸,因此對於普通人來講基金定投是個不錯的選擇,本人也是基金定投的一枚小韭菜。node

基金定投

上班的時候常常心理癢癢,想看看今天的基金又賺(ge)了多少錢,拿出手機打開支付寶的步驟過於繁瑣,並且我也不太關心其餘的指標,只是想知道今天的淨值與漲幅。VS Code 作爲一個編碼工具,提供了強大的插件機制,咱們能夠好好利用這個能力,能夠一邊編碼的時候一邊看看行情。你們能夠在 VS Code 中搜索 「fund-watch」來安裝這個插件。git

示例

實現插件

初始化

VSCode 官方提供了很是方便的插件模板,咱們能夠直接經過 Yeoman 來生成 VS Code 插件的模板。github

先全局安裝 yogenerator-code,運行命令 yo codetypescript

# 全局安裝 yo 模塊
npm install -g yo generator-code

這裏咱們使用 TypeScript 來編寫插件。npm

yo code

yo code

生成後的目錄結構以下:json

目錄結構

VS Code 插件能夠簡單理解爲一個 Npm 包,也須要一個 package.json 文件,屬性與 Npm 包的基本一致。api

{
  // 名稱
  "name": "fund-watch",
  // 版本
  "version": "1.0.0",
  // 描述
  "description": "實時查看基金行情",
  // 發佈者
  "publisher": "shenfq",
  // 版本要求
  "engines": {
    "vscode": "^1.45.0"
  },
  // 入口文件
  "main": "./out/extension.js",
  "scripts": {
    "compile": "tsc -p ./",
    "watch": "tsc -watch -p ./",
  },
  "devDependencies": {
    "@types/node": "^10.14.17",
    "@types/vscode": "^1.41.0",
    "typescript": "^3.9.7"
  },
  // 插件配置
  "contributes": {},
  // 激活事件
  "activationEvents": [],
}

簡單介紹下其中比較重要的配置。數組

  • contributes:插件相關配置。
  • activationEvents:激活事件。
  • main:插件的入口文件,與 Npm 包表現一致。
  • namepublisher:name 是插件名,publisher 是發佈者。${publisher}.${name} 構成插件 ID。

比較值得關注的就是 contributesactivationEvents 這兩個配置。promise

建立視圖

咱們首先在咱們的應用中建立一個視圖容器,視圖容器簡單來講一個單獨的側邊欄,在 package.jsoncontributes.viewsContainers 中進行配置。bash

{
  "contributes": {
    "viewsContainers": {
      "activitybar": [
        {
          "id": "fund-watch",
          "title": "FUND WATCH",
          "icon": "images/fund.svg"
        }
      ]
    }
  }
}

側邊欄

而後咱們還須要添加一個視圖,在 package.jsoncontributes.views 中進行配置,該字段爲一個對象,它的 Key 就是咱們視圖容器的 id,值爲一個數組,表示一個視圖容器內可添加多個視圖。

{
  "contributes": {
    "viewsContainers": {
      "activitybar": [
        {
          "id": "fund-watch",
          "title": "FUND WATCH",
          "icon": "images/fund.svg"
        }
      ]
    },
    "views": {
      "fund-watch": [
        {
          "name": "自選基金",
          "id": "fund-list"
        }
      ]
    }
  }
}

若是你不但願在自定義的視圖容器中添加,能夠選擇 VS Code 自帶的視圖容器。

  • explorer: 顯示在資源管理器側邊欄
  • debug: 顯示在調試側邊欄
  • scm: 顯示在源代碼側邊欄
{
  "contributes": {
    "views": {
      "explorer": [
        {
          "name": "自選基金",
          "id": "fund-list"
        }
      ]
    }
  }
}

顯示到資源管理器中

運行插件

使用 Yeoman 生成的模板自帶 VS Code 運行能力。

vscode配置

切換到調試面板,直接點擊運行,就能看到側邊欄多了個圖標。

調試面板

運行結果

添加配置

咱們須要獲取基金的列表,固然須要一些基金代碼,而這些代碼咱們能夠放到 VS Code 的配置中。

{
  "contributes": {
    // 配置
    "configuration": {
      // 配置類型,對象
      "type": "object",
      // 配置名稱
      "title": "fund",
      // 配置的各個屬性
      "properties": {
        // 自選基金列表
        "fund.favorites": {
          // 屬性類型
          "type": "array",
          // 默認值
          "default": [
            "163407",
            "161017"
          ],
          // 描述
          "description": "自選基金列表,值爲基金代碼"
        },
        // 刷新時間的間隔
        "fund.interval": {
          "type": "number",
          "default": 2,
          "description": "刷新時間,單位爲秒,默認 2 秒"
        }
      }
    }
  }
}

視圖數據

咱們回看以前註冊的視圖,VS Code 中稱爲樹視圖。

"views": {
  "fund-watch": [
    {
      "name": "自選基金",
      "id": "fund-list"
    }
  ]
}

咱們須要經過 vscode 提供的 registerTreeDataProvider 爲視圖提供數據。打開生成的 src/extension.ts 文件,修改代碼以下:

// vscode 模塊爲 VS Code 內置,不須要經過 npm 安裝
import { ExtensionContext, commands, window, workspace } from 'vscode';
import Provider from './Provider';

// 激活插件
export function activate(context: ExtensionContext) {
  // 基金類
  const provider = new Provider();

  // 數據註冊
  window.registerTreeDataProvider('fund-list', provider);
}

export function deactivate() {}

這裏咱們經過 VS Code 提供的 window.registerTreeDataProvider 來註冊數據,傳入的第一個參數表示視圖 ID,第二個參數是 TreeDataProvider 的實現。

TreeDataProvider 有兩個必須實現的方法:

  • getChildren:該方法接受一個 element,返回 element 的子元素,若是沒有element,則返回的是根節點的子元素,咱們這裏由於是單列表,因此不會接受 element 元素;
  • getTreeItem:該方法接受一個 element,返回視圖單行的 UI 數據,須要對 TreeItem 進行實例化;

咱們經過 VS Code 的資源管理器來展現下這兩個方法:

方法展現

有了上面的知識,咱們就能夠輕鬆爲樹視圖提供數據了。

import { workspace, TreeDataProvider, TreeItem } from 'vscode';

export default class DataProvider implements TreeDataProvider<string> {
  refresh() {
    // 更新視圖
  }

  getTreeItem(element: string): TreeItem {
    return new TreeItem(element);
  }

  getChildren(): string[] {
    const { order } = this;
    // 獲取配置的基金代碼
    const favorites: string[] = workspace
      .getConfiguration()
      .get('fund-watch.favorites', []);
    
    // 依據代碼排序
        return favorites.sort((prev, next) => (prev >= next ? 1 : -1) * order);
  }
}

如今運行以後,可能會發現視圖上沒有數據,這是由於沒有配置激活事件。

{
    "activationEvents": [
    // 表示 fund-list 視圖展現時,激活該插件
        "onView:fund-list"
    ]
}

基金代碼列表

請求數據

咱們已經成功將基金代碼展現在視圖上,接下來就須要請求基金數據了。網上有不少基金相關 api,這裏咱們使用每天基金網的數據。

每天基金網

經過請求能夠看到,每天基金網經過 JSONP 的方式獲取基金相關數據,咱們只須要構造一個 url,並傳入當前時間戳便可。

const url = `https://fundgz.1234567.com.cn/js/${code}.js?rt=${time}`

VS Code 中請求數據,須要使用內部提供的 https 模塊,下面咱們新建一個 api.ts

import * as https from 'https';

// 發起 GET 請求
const request = async (url: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    https.get(url, (res) => {
      let chunks = '';
      if (!res || res.statusCode !== 200) {
        reject(new Error('網絡請求錯誤!'));
        return;
      }
      res.on('data', (chunk) => chunks += chunk.toString('utf8'));
      res.on('end', () => resolve(chunks));
    });
  });
};

interface FundInfo {
  now: string
  name: string
  code: string
  lastClose: string
  changeRate: string
  changeAmount: string
}

// 根據基金代碼請求基金數據
export default function fundApi(codes: string[]): Promise<FundInfo[]> {
  const time = Date.now();
    // 請求列表
  const promises: Promise<string>[] = codes.map((code) => {
    const url = `https://fundgz.1234567.com.cn/js/${code}.js?rt=${time}`;
    return request(url);
  });
  return Promise.all(promises).then((results) => {
    const resultArr: FundInfo[] = [];
    results.forEach((rsp: string) => {
      const match = rsp.match(/jsonpgz\((.+)\)/);
      if (!match || !match[1]) {
        return;
      }
      const str = match[1];
      const obj = JSON.parse(str);
      const info: FundInfo = {
        // 當前淨值
        now: obj.gsz,
        // 基金名稱
        name: obj.name,
        // 基金代碼
        code: obj.fundcode,
        // 昨日淨值
        lastClose: obj.dwjz,
        // 漲跌幅
        changeRate: obj.gszzl,
        // 漲跌額
        changeAmount: (obj.gsz - obj.dwjz).toFixed(4),
      };
      resultArr.push(info);
    });
    return resultArr;
  });
}

接下來修改視圖數據。

import { workspace, TreeDataProvider, TreeItem } from 'vscode';
import fundApi from './api';

export default class DataProvider implements TreeDataProvider<FundInfo> {
  // 省略了其餘代碼
  getTreeItem(info: FundInfo): TreeItem {
    // 展現名稱和漲跌幅
      const { name, changeRate } = info
    return new TreeItem(`${name}  ${changeRate}`);
  }

  getChildren(): Promise<FundInfo[]> {
    const { order } = this;
    // 獲取配置的基金代碼
    const favorites: string[] = workspace
      .getConfiguration()
      .get('fund-watch.favorites', []);
    
    // 獲取基金數據
        return fundApi([...favorites]).then(
      (results: FundInfo[]) => results.sort(
          (prev, next) => (prev.changeRate >= next.changeRate ? 1 : -1) * order
        )
    );
  }
}

視圖數據

美化格式

前面咱們都是經過直接實例化 TreeItem 的方式來實現 UI 的,如今咱們須要從新構造一個 TreeItem

import { workspace, TreeDataProvider, TreeItem } from 'vscode';
import FundItem from './TreeItem';
import fundApi from './api';

export default class DataProvider implements TreeDataProvider<FundInfo> {
  // 省略了其餘代碼
  getTreeItem(info: FundInfo): FundItem {
    return new FundItem(info);
  }
}
// TreeItem
import { TreeItem } from 'vscode';

export default class FundItem extends TreeItem {
  info: FundInfo;

  constructor(info: FundInfo) {
    const icon = Number(info.changeRate) >= 0 ? '📈' : '📉';

    // 加上 icon,更加直觀的知道是漲仍是跌
    super(`${icon}${info.name}   ${info.changeRate}%`);

    let sliceName = info.name;
    if (sliceName.length > 8) {
      sliceName = `${sliceName.slice(0, 8)}...`;
    }
    const tips = [
      `代碼: ${info.code}`,
      `名稱: ${sliceName}`,
      `--------------------------`,
      `單位淨值:    ${info.now}`,
      `漲跌幅:     ${info.changeRate}%`,
      `漲跌額:     ${info.changeAmount}`,
      `昨收:      ${info.lastClose}`,
    ];

    this.info = info;
    // tooltip 鼠標懸停時,展現的內容
    this.tooltip = tips.join('\r\n');
  }
}

美化後

更新數據

TreeDataProvider 須要提供一個 onDidChangeTreeData 屬性,該屬性是 EventEmitter 的一個實例,而後經過觸發 EventEmitter 實例進行數據的更新,每次調用 refresh 方法至關於從新調用了 getChildren 方法。

import { workspace, Event, EventEmitter, TreeDataProvider } from 'vscode';
import FundItem from './TreeItem';
import fundApi from './api';

export default class DataProvider implements TreeDataProvider<FundInfo> {
  private refreshEvent: EventEmitter<FundInfo | null> = new EventEmitter<FundInfo | null>();
  readonly onDidChangeTreeData: Event<FundInfo | null> = this.refreshEvent.event;

  refresh() {
    // 更新視圖
    setTimeout(() => {
      this.refreshEvent.fire(null);
    }, 200);
  }
}

咱們回到 extension.ts,添加一個定時器,讓數據定時更新。

import { ExtensionContext, commands, window, workspace } from 'vscode'
import Provider from './data/Provider'

// 激活插件
export function activate(context: ExtensionContext) {
  // 獲取 interval 配置
  let interval = workspace.getConfiguration().get('fund-watch.interval', 2)
  if (interval < 2) {
    interval = 2
  }

  // 基金類
  const provider = new Provider()

  // 數據註冊
  window.registerTreeDataProvider('fund-list', provider)

  // 定時更新
  setInterval(() => {
    provider.refresh()
  }, interval * 1000)
}

export function deactivate() {}

除了定時更新,咱們還須要提供手動更新的能力。修改 package.json,註冊命令。

{
  "contributes": {
        "commands": [
            {
                "command": "fund.refresh",
                "title": "刷新",
                "icon": {
                    "light": "images/light/refresh.svg",
                    "dark": "images/dark/refresh.svg"
                }
            }
        ],
        "menus": {
            "view/title": [
                {
                    "when": "view == fund-list",
                    "group": "navigation",
                    "command": "fund.refresh"
                }
            ]
        }
    }
}
  • commands:用於註冊命令,指定命令的名稱、圖標,以及 command 用於 extension 中綁定相應事件;
  • menus:用於標記命令展現的位置;

    • when:定義展現的視圖,具體語法能夠查閱官方文檔
    • group:定義菜單的分組;
    • command:定義命令調用的事件;

view-actions

配置好命令後,回到 extension.ts 中。

import { ExtensionContext, commands, window, workspace } from 'vscode';
import Provider from './Provider';

// 激活插件
export function activate(context: ExtensionContext) {
  let interval = workspace.getConfiguration().get('fund-watch.interval', 2);
  if (interval < 2) {
    interval = 2;
  }

  // 基金類
  const provider = new Provider();

  // 數據註冊
  window.registerTreeDataProvider('fund-list', provider);

  // 定時任務
  setInterval(() => {
    provider.refresh();
  }, interval * 1000);

  // 事件
  context.subscriptions.push(
    commands.registerCommand('fund.refresh', () => {
      provider.refresh();
    }),
  );
}

export function deactivate() {}

如今咱們就能夠手動刷新了。

image-20200824113219392

新增基金

咱們新增一個按鈕用了新增基金。

{
  "contributes": {
        "commands": [
      {
        "command": "fund.add",
        "title": "新增",
        "icon": {
          "light": "images/light/add.svg",
          "dark": "images/dark/add.svg"
        }
      },
            {
                "command": "fund.refresh",
                "title": "刷新",
                "icon": {
                    "light": "images/light/refresh.svg",
                    "dark": "images/dark/refresh.svg"
                }
            }
        ],
        "menus": {
            "view/title": [
        {
          "command": "fund.add",
          "when": "view == fund-list",
          "group": "navigation"
        },
                {
                    "when": "view == fund-list",
                    "group": "navigation",
                    "command": "fund.refresh"
                }
            ]
        }
    }
}

extension.ts 中註冊事件。

import { ExtensionContext, commands, window, workspace } from 'vscode';
import Provider from './Provider';

// 激活插件
export function activate(context: ExtensionContext) {
  // 省略部分代碼 ...
  
  // 基金類
  const provider = new Provider();

  // 事件
  context.subscriptions.push(
    commands.registerCommand('fund.add', () => {
      provider.addFund();
    }),
    commands.registerCommand('fund.refresh', () => {
      provider.refresh();
    }),
  );
}

export function deactivate() {}

實現新增功能,修改 Provider.ts

import { workspace, Event, EventEmitter, TreeDataProvider } from 'vscode';
import FundItem from './TreeItem';
import fundApi from './api';

export default class DataProvider implements TreeDataProvider<FundInfo> {
  // 省略部分代碼 ...

  // 更新配置
  updateConfig(funds: string[]) {
    const config = workspace.getConfiguration();
    const favorites = Array.from(
      // 經過 Set 去重
      new Set([
        ...config.get('fund-watch.favorites', []),
        ...funds,
      ])
    );
    config.update('fund-watch.favorites', favorites, true);
  }

  async addFund() {
    // 彈出輸入框
    const res = await window.showInputBox({
      value: '',
      valueSelection: [5, -1],
      prompt: '添加基金到自選',
      placeHolder: 'Add Fund To Favorite',
      validateInput: (inputCode: string) => {
        const codeArray = inputCode.split(/[\W]/);
        const hasError = codeArray.some((code) => {
          return code !== '' && !/^\d+$/.test(code);
        });
        return hasError ? '基金代碼輸入有誤' : null;
      },
    });
    if (!!res) {
      const codeArray = res.split(/[\W]/) || [];
      const result = await fundApi([...codeArray]);
      if (result && result.length > 0) {
        // 只更新能正常請求的代碼
        const codes = result.map(i => i.code);
        this.updateConfig(codes);
        this.refresh();
      } else {
        window.showWarningMessage('stocks not found');
      }
    }
  }
}

新增按鈕

輸入框

刪除基金

最後新增一個按鈕,用來刪除基金。

{
    "contributes": {
        "commands": [
            {
                "command": "fund.item.remove",
                "title": "刪除"
            }
        ],
        "menus": {
      // 這個按鈕放到 context 中
      "view/item/context": [
        {
          "command": "fund.item.remove",
          "when": "view == fund-list",
          "group": "inline"
        }
      ]
        }
  }
}

extension.ts 中註冊事件。

import { ExtensionContext, commands, window, workspace } from 'vscode';
import Provider from './Provider';

// 激活插件
export function activate(context: ExtensionContext) {
  // 省略部分代碼 ...
  
  // 基金類
  const provider = new Provider();

  // 事件
  context.subscriptions.push(
    commands.registerCommand('fund.add', () => {
      provider.addFund();
    }),
    commands.registerCommand('fund.refresh', () => {
      provider.refresh();
    }),
    commands.registerCommand('fund.item.remove', (fund) => {
      const { code } = fund;
      provider.removeConfig(code);
      provider.refresh();
    })
  );
}

export function deactivate() {}

實現新增功能,修改 Provider.ts

import { window, workspace, Event, EventEmitter, TreeDataProvider } from 'vscode';
import FundItem from './TreeItem';
import fundApi from './api';

export default class DataProvider implements TreeDataProvider<FundInfo> {
  // 省略部分代碼 ...

  // 刪除配置
  removeConfig(code: string) {
    const config = workspace.getConfiguration();
    const favorites: string[] = [...config.get('fund-watch.favorites', [])];
    const index = favorites.indexOf(code);
    if (index === -1) {
      return;
    }
    favorites.splice(index, 1);
    config.update('fund-watch.favorites', favorites, true);
  }
}

刪除按鈕

總結

實現過程當中也遇到了不少問題,遇到問題能夠多翻閱 VSCode 插件中文文檔。該插件已經發布的了 VS Code 插件市場,感興趣的能夠直接下載該插件,或者在 github 上下載完整代碼

image

相關文章
相關標籤/搜索