Vscode 扩展开发实践 jump源码分析

1,827 阅读5分钟

用过谷歌扩展vimium的同学,肯定都知道键盘流的好处。另外在 vim 编辑模式下体会光标快速游走是何等舒服的一件事。这一切,就像农药里的韩信,谁用谁知道...接下来,不是讲vim,而是一步一步带领大家carry vscode 扩展开发,最终实现 vscode 中 光标快速游走。

可以在 vscode 扩展安装中搜索 jumpy 来安装...

PS:vscode 扩展推荐使用 Typescript 来开发,一来多一门js超集来强身,二来vscode中的智能提示神马的都跟 Typescript 有着密切的关系。

Jump源码分析

主要从以下三个方面深入:

  • Vscode 扩展开发入门
  • Typescript 涉及知识点
  • jump 源码分析

Vscode 扩展开发入门

安装

cnpm install -g yo generator-code
yo code

目录说明

两个比较重要的文件:

  • package.json 用于描述你的插件及命令。
  • extension.js 入口文件,用于提供命令代码。这个文件暴露了一个函数 activate,当插件激活时执行。通过 registerCommand 注册命令。

开始

  • F5 会打开一个新窗口来运行你的插件。
  • 在命令面板中执行你的命令。 (Ctrl+Shift+P or Cmd+Shift+P on Mac)
  • extension.js 文件中打断点来调试。
  • 在调试控制台中查看调试记录。

安装扩展

个人扩展文件夹

VS Code 会在个人扩展文件夹中.vscode/extensions来寻找扩展组件。不同的平台其文件夹所在的位置也不同:

  • Windows %USERPROFILE%\.vscode\extensions
  • Mac ~/.vscode/extensions
  • Linux ~/.vscode/extensions

如果你想在 VS Code 每次启动都能够加载你自己的扩展或者定制化信息,那么就需要在.vscode/extensions文件夹下新建一个文件夹,并把项目文件放进去。例如:~/.vscode/extensions/myextension

package.json 详解

Contribution

package.json extension manifest 中 contribution 选项的所有可用字段。

  • configuration 选项会被暴露给用户。用户能够在“用户设置”或“工作区设置”面板中设置这些配置选项。
  • commands 提供了一个由 commands 和 title 字段组成的条目,用于在 命令面板 中调用。
  • keybindings
  • menus
  • languages 提供一种语言的定义。这会引入一门新的语言或者提升 VS Code 关于一门语言的认知。
  • debuggers 为 VS Code 设置一个调试器。 调试器可以具有以下属性。
  • grammars 设置一种语言的 TextMate 语法。您必须提供此语法适用的 language 字段,语法和文件路径的 TextMate scopeName。
  • themes
  • snippets 为某一个具体的语言提供代码片段。
  • jsonValidation 为特定类型的 json 文件贡献一个验证模式。 url 值可以是包含在扩展中的模式文件的本地路径,也可以是远程服务器 URL(如 json 模式存储)。

Activation Events

提供 以下 activation events:

  • onLanguage:${language} 指定 Language 后触发
  • onCommand:${command} 指定 命令执行后触发
  • workspaceContains:${toplevelfilename} 指定文件打开后触发
  • * vscode 启动后触发(慎用)

其他

插件包含了以下组件的支持:

  • 激活 - 当检测到指定的文件类型,或者指定的文件存在,或者通过命令面板或者键盘快捷键选中一条命令时加载插件
  • 编辑器 - 用来处理编辑器的内容 - 读和控制文本, 使用选择区域
  • 工作空间 - 访问打开的文件, 状态栏, 信息提示等
  • 事件 - 连接编辑器的生命周期,类似:打开,关闭,修改等等
  • 高级编辑器 - 为高级语言提供包括智能感知,预览, 悬停, 诊断以及更多的支持

API 概览

API 按命名空间组织,全局命名空间如下:

  • commands 执行/注册命令,IDE 自身的和其它插件注册的命令都可以,如 executeCommand
  • debug 调试相关 API,比如 startDebugging
  • env IDE 相关的环境信息,比如 machineId, sessionId
  • extensions 跨插件 API 调用,extensionDependency 声明插件依赖
  • languages 编程语言相关 API,如 createDiagnosticCollection, registerDocumentFormattingEditProvider
  • scm 源码版本控制 API,如 createSourceControl
  • window 编辑器窗体相关 API,如 onDidChangeTextEditorSelection, createTerminal, showTextDocument
  • workspace 工作空间级 API(打开了文件夹才有工作空间),如 findFiles, openTextDocument, saveAll

Typescript 涉及知识点

本次jump源码中涉及的Typescript知识点并不多,主要是添加了类型检查的功能。其余跟Es6类似的功能这里不做延伸。

基础类型

  • 布尔值

let isDone:boolean = false

  • 数字

let decLiteral:number = 6

  • 字符串

let name:string = "bob"

  • 数组 有两种方式可以定义数组

    • 在元素类型后面接上[] let list:number[] = [1,2,3]
    • 使用数组泛型,Array<元素类型> let list:Array<number> = [1,2,3]
  • 元组 Tuple

允许表示一个已知元素数量和类型的数组,各元素的类型不必相同 let x:[string,number] 当访问一个越界的元素,会使用联合类型替代。x[3] = 'world' 字符串可以赋值给 (string|number) 类型

  • 枚举 enum

enum Color {Red,Green,Blue}

枚举类型供的一个便利是你可以由枚举的值得到它的名字。例如,我们知道数值 2,但是不确定它隐射到 Color 里的哪个名字。

enum Color {
  Red = 1,
  Green,
  Blue
}
let colorName: string = Color[2];
  • Any

这些值可能来自于动态的内容 let notSure:any = 4

Object 有相似的作用,但是 Object 类型的变量只是允许你给它赋任意值,但是却不能够在它上面调用任意的方法,即便它真的有这些方法

let notSoure: any = 4;
notSoure.toFixed(); // ok

let prettySure: Object = 4;
prettySure.toFixed(); // Error

当你只知道一部分数据的类型时,any 类型也是有用的。

let list: any[] = [1, true, 'free'];
list[1] = 100;
  • void

类型与 any 类型相反,它表示没有任何类型。当一个函数没有返回值时,你通常会见到其返回值类型时 void

function warnUser(): void {
  alert('This is my warning message');
}

声明一个 void 类型的变量没有什么大用,因为你只能为它赋予 undefined 和 null

  • undefined null

Typescript 里,undefined 和 null 两者各自有自己的类型分别叫做 undefined 和 null。和 void 相似,它们的本身的类型用处不是很大

默认情况下 null 和 undefined 是所有类型的子类型。就是说你可以把 null 和 undefined 赋值给 number 类型的变量。

  • Never

Never 类型 表示的是那些永不存在的值得类型。例如,never 类型 是哪些总是会抛出异常或者根本就不会有返回值的函数表达式或箭头函数表达式的返回值类型。never 类型是任何类型的子类型,也可以赋值给任何类型,即使 any 也不可以赋值给 never。

function error(message: string): never {
  throw new Error(message);
}
  • 类型断言

类型断言有两种形式,其一是"尖括号"语法:

let someValue: any = 'this is a string';
let strLength: number = (<string>someValue).length;

另一个为 as 语法:

let someValue: any = 'this is a string';

let strLength: number = (someValue as string).length;

两种形式是等价的。至于使用哪个大多数情况下是凭个人喜好;然而,当你在 typescript 里使用 JSX 时,只有 as 语法断言是被允许的。

接口

Typescript 的核心原则之一是对值所具有的结构进行类型检查。它有时被称做"鸭式辩型法"或者"结构性子类型化"。在 TypeScript 里,接口的作用就是为这些类型命名和为你的代码或第三方代码定义契约。

接口初探

下面通过一个简单示例来观察接口是如何工作的。

function printLabel(labelledObj: { label: string }) {
  console.log(labelledObj.label);
}

let myObj = { size: 10, label: 'size 10 Object' };
printLabel(myObj);

类型检查器会查看 printLabel 的调用。printLabe 有一个参数,并要求这个对象参数有一个名为 label 类型为 string 的属性。

需要注意的是,我们传入的对象参数实际上会包含很多属性,但是编译器只会检查那些必需的属性是否存在,并且其类型是否匹配然而,有些时候 Typescript 却并不会这么宽松

interface LabelledValue {
  label: string;
}

function printLabel(labelledObj: LabelledValue) {
  console.log(labelledObj.label);
}

let myObj = { size: 10, label: 'size 10 Object' };
printLabel(myobj);

"option bags"例子

interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  let newSquare = { color: 'white', area: 100 };
  if (config.color) {
    newSquare.color = config.color;
  }
  if (config.width) {
    newSquare.area = config.width * config.width;
  }
  return newSquare;
}

let mySquare = createSquare({ color: 'black' });

带有可选属性的接口与普通的接口定义差不多,只是在可选属性名字定义的后面加了一个?符号。

可选属性的好处之一是可以对可能存在的属性进行预定义,好处之二是可以捕获引用了不存在的属性时的错误。

只读属性

一些对象属性只能在对象刚刚创建的时候修改其值。你可以在属性名前用 readonly 来指定只读属性

interface Point {
  readonly x: number;
  readonly y: number;
}

你可以通过赋值一个对象字面量来构造一个 Point。赋值后,x 和 y 再也不能改变了。

let p1: Point = { x: 10, y: 20 };
p1.x = 5;

TypeScript 具有 ReadonlyArray类型,它与 Array相似,只是把所有可变方法去掉了,因此可以确保数组创建后再也不能被修改。

let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray<number> = a;

就算把整个 ReadonlyArray 赋值到一个普通数组也是不可以的。但是你可以用类型断言重写

a = ro as number[]

最简单判断改用 readonly 还是 const 的方法是看要把它作为变量使用还是作为一个属性。作为变量使用的话用 const,若作为属性则使用 readonly

函数类型

为了使用接口表示函数类型,我们需要给接口定义一个调用签名。它就像是一个只有参数列表和返回值类型的函数定义。参数列表里的每个参数都需要名字和类型。

interface SearchFunc {
  (source: string, subString: string): boolean;
}

可索引的类型

与使用接口描述函数类型差不多,我们也可以描述那些能够"通过索引得到"的类型

interface StringArray {
  [index: number]: string;
}

let myArray: StringArray;
myArray = ['Bob', 'Fred'];

let myStr: string = myArray[0];

jump 源码分析

jump 主要用于光标定位跳转,步骤如下:

  • 匹配需要跳转的位置,提供跳转位置标识符
  • 跳转逻辑实现

跳转位置标识符生成

1、生成由字母 a-z 组合的标识符

const numCharCodes = 26;
const codeArray = createCodeArray();
function createCodeArray(): string[] {
  const codeArray = new Array(numCharCodes * numCharCodes);
  let codeIndex = 0;
  for (let i = 0; i < numCharCodes; i++) {
    for (let j = 0; j < numCharCodes; j++) {
      codeArray[codeIndex++] =
        String.fromCharCode(97 + i) + String.fromCharCode(97 + j);
    }
  }

  return codeArray;
}

2、转为可用于在编辑器上展示的 svg 图标,分别有黑白两个主题样式。

let darkDataUriCache: { [index: string]: vscode.Uri } = {};
let lightDataUriCache: { [index: string]: vscode.Uri } = {};

createDataUriCaches(codeArray);

function createDataUriCaches(codeArray: string[]) {
  codeArray.forEach(
    code => (darkDataUriCache[code] = getSvgDataUri(code, 'white', 'black'))
  );
  codeArray.forEach(
    code => (lightDataUriCache[code] = getSvgDataUri(code, 'black', 'white'))
  );
}

function getSvgDataUri(
  code: string,
  backgroundColor: string,
  fontColor: string
) {
  const width = code.length * 7;
  return vscode.Uri.parse(
    `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} 13" height="13" width="${width}"><rect width="${width}" height="13" rx="2" ry="2" style="fill: ${backgroundColor};"></rect><text font-family="Consolas" font-size="11px" fill="${fontColor}" x="1" y="10">${code}</text></svg>`
  );
}

编辑器修饰实现

在之前的基础上,已经有对应的 svg URI 数组了。接下来实现如何把这些标识符显示在编辑器上。

首先在 vscode 中,打开的编辑器称为 window.activeTextEditor,该实例有个用于修改编辑器修饰的方法 setDecorations

const editor = window.activeTextEditor;
const decorationType = window.createTextEditorDecorationType({});
const decorations = [
  {
    range: new vscode.Range(line, startCharacter, line, endCharacter),
    renderOptions: {
      dark: {
        before: {
          contentIconPath: darkDataUriCache[code]
        }
      },
      light: {
        before: {
          contentIconPath: lightDataUriCache[code]
        }
      }
    }
  }
];
editor.setDecorations(decorationType, decorations);

vscode 文档中这么 描述 [setDecorations](https://code.visualstudio.com/docs/extensionAPI/vscode-api#_commands)

Adds a set of decorations to the text editor. If a set of decorations already exists with the given decoration type, they will be replaced.

第二个参数比较重要的 选项 range

A range represents an ordered pair of two positions. It is guaranteed that start.isBeforeOrEqual(end)

Range objects are immutable. Use the with, intersection, or union methods to derive new ranges from an existing range.

表示在哪个区域添加什么修饰。这里的作用是在找出匹配后的位置,在contentIconPath添加带有字符组合 svg 图标。这里会用到两个方法 getLinesgetPosition 分别获取new vscode.Range(line, startCharacter, line, endCharacter)中对应的参数值。

function getPosition(
  maxDecorations: number,
  firstLineNumber: number,
  lines: string[],
  regexp: RegExp
): JumpPosition[] {
  let positionIndex = 0;
  const positions: JumpPosition[] = [];

  //   获取匹配后的字符所在 的位置,包含 行和字符所在位置。
  //   可以想象成 距离编辑器左上角 x ,y 坐标
  for (let i = 0; i < lines.length && positionIndex < maxDecorations; i++) {
    let lineText = lines[i];
    let word: RegExpExecArray;
    while (!!(word = regexp.exec(lineText)) && positionIndex < maxDecorations) {
      positions.push({
        line: i + firstLineNumber,
        character: word.index
      });
    }
  }
  return positions;
}

// 由于编辑存在滚动条,这里获取的行主要用于表示从 哪个行开始展示 修饰标识符
function getLines(
  editor: vscode.TextEditor
): { firstLineNumber: number; lines: string[] } {
  const document = editor.document;
  const activePosition = editor.selection.active;

  const startLine =
    activePosition.line < plusMinusLines
      ? 0
      : activePosition.line - plusMinusLines;
  const endLine =
    document.lineCount - activePosition.line < plusMinusLines
      ? document.lineCount
      : activePosition.line + plusMinusLines;

  const lines: string[] = [];
  for (let i = startLine; i < endLine; i++) {
    lines.push(document.lineAt(i).text);
  }

  return {
    firstLineNumber: startLine,
    lines
  };
}

至此 字符匹配后生产对应的标识符 已实现。

跳转逻辑实现

交互流程:标识符出现后,键入对应的字符组合后光标跳转到对应位置。

需要做两件事。

1、启动 jump 模式,拦截编辑器 type 输入 2、键入对应字母组合后跳转至对应位置

let isJumpMode: boolean = false;
let firstKeyOfCode: string = null;

// 模式设置方法
function setJumpMode(value: boolean) {
  isJumpMode = value;
  commands.executeCommand('setContext', 'jump.isJumpMode', value);
}

// 在执行jump命令后,开启jump模式
setJumpMode(true);

// 注册 type 命令来修改 type 后的逻辑,type的意思是在编辑器中输入字符
const jumpTypeDisposable = commands.registerCommand('type', args => {
  if (!isJumpMode) {
    // 如果不是 jump 模式,恢复 type 默认模式
    commands.executeCommand('default:type', args);
    return;
  }

  const editor = window.activeTextEditor;
  const text: string = args.text;

  // 如果键入的字符 不是 之前 字母组合中的 则退出 jump 模式。
  if (text.search(/[a-z]/i) === -1) {
    exitJumpMode();
    return;
  }

  // 由于 标识符 都是 两个字母,所以需要记录第一个字母
  if (!firstKeyOfCode) {
    firstKeyOfCode = text;
    return;
  }

  const code = firstKeyOfCode + text;
  const position = positions[getCodeIndex(code.toLowerCase())];
  const { line, character } = position;

  // 清空编辑器上的 字母组合标识符
  editor.setDecorations(decorationType, []);

  // 通过 selection 来实现光标的移动。
  window.activeTextEditor.selection = new Selection(
    line,
    character,
    line,
    character
  );

  const reviewType: TextEditorRevealType = TextEditorRevealType.Default;
  window.activeTextEditor.revealRange(
    window.activeTextEditor.selection,
    reviewType
  );

  setJumpMode(false);
});

至此一个简单的光标快速跳转插件的核心代码已经讲解完毕,谢谢!