博客
博客
文章目录
  1. 写在前面
  2. 实现插件
    1. 初始化
    2. 创建视图
    3. 运行插件
    4. 添加配置
    5. 视图数据
    6. 请求数据
    7. 美化格式
    8. 更新数据
    9. 新增基金
    10. 删除基金
  3. 总结

从零开始实现 VS Code 基金插件

写在前面

随着 7 月一波牛市行情,越来越多的人投身 A 股行列,但是股市的风险巨大,有人一夜暴富,也有人血本无归,所以对于普通人来说基金定投是个不错的选择,本人也是基金定投的一枚小韭菜。

基金定投

上班的时候经常心理痒痒,想看看今天的基金又赚(ge)了多少钱,拿出手机打开支付宝的步骤过于繁琐,而且我也不太关心其他的指标,只是想知道今天的净值与涨幅。VS Code 做为一个编码工具,提供了强大的插件机制,我们可以好好利用这个能力,可以一边编码的时候一边看看行情。

示例

实现插件

初始化

VSCode 官方提供了非常方便的插件模板,我们可以直接通过 Yeoman 来生成 VS Code 插件的模板。

先全局安装 yogenerator-code,运行命令 yo code

# 全局安装 yo 模块 
npm install -g yo generator-code

这里我们使用 TypeScript 来编写插件。

yo code

yo code

生成后的目录结构如下:

目录结构

VS Code 插件可以简单理解为一个 Npm 包,也需要一个 package.json 文件,属性与 Npm 包的基本一致。

{
// 名称
"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 这两个配置。

创建视图

我们首先在我们的应用中创建一个视图容器,视图容器简单来说一个单独的侧边栏,在 package.jsoncontributes.viewsContainers 中进行配置。

{
"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 上下载 完整代码

支持一下
扫一扫,支持一下
  • 微信扫一扫
  • 支付宝扫一扫