专栏首页黯羽轻扬TypescriptServerPlugin_VSCode插件开发笔记3

TypescriptServerPlugin_VSCode插件开发笔记3

一.需求场景

VS Code能够正确支持JS/TS跳转到定义、补全提示等功能,但仅限于符合Node Module Resolution以及TypeScript Module Resolution规则的模块引用,如:

// 标准import
import {fn1, fn2} from './myModule.js';// node modules引用
var x = require("./moduleB");// TypeScript
// 允许省略后缀名
import {fn1, fn2} from './myModule';
// 支持baseUrl
import { localize } from 'vs/nls';

如果是其它自定义import规则,这些功能都将不可用(无法跳转、没有提示、没有Lint校验……),例如:

  • Webpack Resolve:import Utility from 'Utilities/utility';
  • React’s Haste:var ReactCurrentOwner = require('ReactCurrentOwner');

import规则特殊导致这些关键功能不可用,对于建立在类似构建工具上的项目而言是个痛点,期望通过插件来解决

二.实现思路

立足VS Code插件机制,想要支持特殊import规则的话,有2种方式:

  • registerDefinitionProvider
  • TypescriptServerPlugins

前者是通用的Definition扩展方式,具体案例见Show Definitions of a Symbol;后者仅适用于JS/TS,但功能更强大(不限于Definition)

三.registerDefinitionProvider

通过registerDefinitionProvider实现自己的DefinitionProvider是最常见的Go to Definition扩展方式,但存在2个问题

  • 缺少语义支持:仅能获得当前Document以及跳转动作发生的行列位置,没有提供任何代码语义相关的信息。比如是个import语句还是属性访问,是个require函数调用还是字符串字面量,这些关键信息都没有暴露出来
  • 易出现Definition冲突:发生Go to Definition动作时,所有插件(VS Code内置)注册的相关DefinitionProvider都会触发执行,而DefinitionProvider之间并不知道其它DefinitionProvider的存在,自然会出现多个Provider提供了相同或相似Definition的冲突情况

语义支持缺失

缺少语义支持是个硬缺陷,例如经常需要在入口处做类似这样的事情:

getToken() {
   const position = Editor.getEditorCursorStart();
   const token = this.scanReverse(position) + this.scanForward(position);
   if (this.isEmpty(token)) {
       return null;
   }
   return token;
}

(摘自WesleyLuk90/node-import-resolver-code)

更大的问题是受限于触摸不到语法树,很多事情“做不了”,比如:

// 非ES Module标准的Webpack Resolve
import myModule from 'non-es-module-compatible-path-to/my-awesome-module';// 试图跳转到doSomething定义
myModule.doSomething();

想要跳转到依赖文件中的定义,必须要做到这2点:

  • “理解”myModule是个依赖模块,并找到myModule指向的文件
  • “理解”该文件内容的语义,找出doSomething定义所在的行列位置

也就是说,必须对当前文件以及依赖文件内容进行语义分析,而VS Code插件机制并没有开放这种能力

诚然,插件自己(通过Babel等工具)实现语义分析可以应对这种场景,但会发现更多的问题:

  • 输入myModule.缺少补全提示
  • 输入myModule.doAnotherThing(缺少参数提示
  • 输入myModule.undefinedFunction()缺少Lint报错
  • ……

这一整套原本存在的功能现在都要重新实现一遍,投入就像无底洞,我们似乎陷入了一个误区:试图从上层修复下层问题,最后发现要铺满整块地面才能解决(几乎要重新实现整个下层)

Definition冲突

相同/相似Definition的问题主要表现在用户插件与内置插件功能冲突上,由于通过插件API无法获知内置Provider的Definition结果,冲突在所难免

从实现上来看,所有DefinitionProvider提供的Definition结果会被merge到一起:

// ref: https://github.com/Microsoft/vscode/blob/master/src/vs/editor/contrib/goToDefinition/goToDefinition.ts
function getDefinitions<T>(): Promise<DefinitionLink[]> {
 const provider = registry.ordered(model); // get results
 const promises = provider.map((provider): Promise<DefinitionLink | DefinitionLink[] | null | undefined> => {
   return Promise.resolve(provide(provider, model, position)).then(undefined, err => {
     onUnexpectedExternalError(err);
     return null;
   });
 });
 return Promise.all(promises)
   .then(flatten)
   .then(coalesce);
}

VS Code考虑到了重复定义的情况,内部做了去重,但只针对完全相同的定义(即urirange(startLine, startColumn, endLine, endColumn)都完全相同):

the design is to be cooperative and we only de-dupe items that are exactly the same.

对应源码:

// ref: https://github.com/Microsoft/vscode/blob/master/src/vs/editor/contrib/goToDefinition/goToDefinitionCommands.ts
export class DefinitionAction extends EditorAction {
 public run(): TPromise<void> {
   const definitionPromise = this._getDeclarationsAtPosition().then(references => {
     const result: DefinitionLink[] = [];
     for (let i = 0; i < references.length; i++) {
       let reference = references[i];
       let { uri, range } = reference;
       result.push({ uri, range });
     }     if (result.length === 0) {
       // 无definition结果,提示没找到定义
       if (this._configuration.showMessage) {
         const info = model.getWordAtPosition(pos);
         MessageController.get(editor).showMessage(this._getNoResultFoundMessage(info), pos);
       }
     } else if (result.length === 1 && idxOfCurrent !== -1) {
       // 只有1条结果,直接跳转
       let [current] = result;
       this._openReference(editor, editorService, current, false);     } else {
       // 多条结果,去重并显示
       this._onResult(editorService, editor, new ReferencesModel(result));
     }
   });
 }
}

去重逻辑:

// ref: https://github.com/Microsoft/vscode/blob/master/src/vs/editor/contrib/referenceSearch/referencesModel.ts
export class ReferencesModel implements IDisposable {
 constructor(references: Location[]) {
   this._disposables = [];
   // 按字典序对文件路径排序,同一文件内按range起始位置排序
   references.sort(ReferencesModel._compareReferences);   let current: FileReferences;
   for (let ref of references) {
     // 按文件分组
     if (!current || current.uri.toString() !== ref.uri.toString()) {
       // new group
       current = new FileReferences(this, ref.uri);
       this.groups.push(current);
     }     // 去重,滤掉完全相同的range
     if (current.children.length === 0
       || !Range.equalsRange(ref.range, current.children[current.children.length - 1].range)) {       let oneRef = new OneReference(current, ref.range);
       this._disposables.push(oneRef.onRefChanged((e) => this._onDidChangeReferenceRange.fire(e)));
       this.references.push(oneRef);
       current.children.push(oneRef);
     }
   }
 }
}

最后,还有一个重要的展示逻辑:

  private _openReference(): TPromise<ICodeEditor> {
   return editorService.openCodeEditor({
     resource: reference.uri,
     options: {
       // 选中range起始位置(光标移动到该位置)
       selection: Range.collapseToStart(reference.range),
       revealIfOpened: true,
       revealInCenterIfOutsideViewport: true
     }
   }, editor, sideBySide);
 }

这就引发了很容易出现的“重复”定义问题,在显示时,这3个range看起来完全一样

Range(0, 0, 28, 12)
Position(0, 0)
// Position(0, 0)会被转换成
Range(0, 0, 0, 0)

起点位于同一行相近位置的range也难以分辨(如Range(0, 0, _, _)Range(0, 1, _, _)),展示上看起来都是重复定义

要解决类似的“重复”(本质上是DefinitionProvider间的冲突),有两种思路:

  • 猜测内置Provider的行为,主动避开内置插件能够处理的case
  • 由VS Code提供特性支持,比如registerDefinitionProvider提供选项enabelWhenNoReferences(仅在别人都没结果时才走我的)

前者比较脆弱,依靠猜测很难覆盖所有情况,并且一旦内置插件更新了,这些猜测可能就不适用了。而后者目前(2018/12/16)没有提供支持,将来可能也不会提供,具体见Duplicated definition references found when Go to Definition

四.TypescriptServerPlugins

TypeScript server plugins are loaded for all JavaScript and TypeScript files when the user is using VS Code’s version of TypeScript.

简言之,就是通过插件内置指定的TypeScript Language Service Plugin,从而扩展VS Code处理JS/TS的能力

TypeScript Language Service Plugin

TypeScript Language Service Plugins (“plugins”) are for changing the editing experience only.

仅能增强编辑体验,无法改变TS核心行为(比如改变类型检查行为)或增加新特性(比如提供一种新语法或者)

具体的,编辑体验相关的事情包括:

  • 提供Lint报错
  • 处理补全提示列表,滤掉一些东西,比如window.eval
  • 让Go to definition指向不同的引用位置
  • 给字符串字面量形式的自定义模板语言提供报错及补全提示,例如Microsoft/typescript-lit-html-plugin

做不到的事情包括:

  • 给TypeScript添一种新的自定义语法
  • 改变编译器转译出JavaScript的行为
  • 定制类型系统,试图改变tsc命令的校验行为

因此,如果只是想增强编辑体验,TypeScript Language Service Plugin是很不错的选择

示例

VS Code默认行为是无后缀名的优先跳.ts(无论源文件是JS还是TS),如果想要.js文件里的模块引用都指向.js文件的话,可以通过简单的Service Plugin来实现:

function init(modules: { typescript: typeof ts_module }) {
 const ts = modules.typescript; function create(info: ts.server.PluginCreateInfo) {
   const resolveModuleNames = info.languageServiceHost.resolveModuleNames;   // 篡改resolveModuleNames,以扩展自定义行为
   info.languageServiceHost.resolveModuleNames = function(moduleNames: string[], containingFile: string) {
     const isJsFile = containingFile.endsWith('.js');
     let resolvedNames = moduleNames;
     if (isJsFile) {
       const dir = path.dirname(containingFile);
       resolvedNames = moduleNames.map(moduleName => {
         // 仅针对无后缀名的相对路径引用
         const needsToResolve = /^\./.test(moduleName) && !/\.\w+$/.test(moduleName);
         if (needsToResolve) {
           const targetFile = path.resolve(dir, moduleName + '.js');
           if (ts.sys.fileExists(targetFile)) {
             // 添上.js后缀名,要求跳转到.js文件
             return moduleName + '.js';
           }
         }         return moduleName;
       });       return resolveModuleNames.call(info.languageServiceHost, resolvedNames, containingFile);
     }     return info.languageService;
   }
 } return { create };
}

其中,moduleNames就是在语法分析完成之后收集到的import模块名,也就是说,TypeScript Language Service Plugin有语义支持

P.S.更多类似示例,见:

  • HelloWorld:滤掉补全提示中的某些项,如caller
  • KnisterPeter/typescript-patternplate-resolver

contributes.typescriptServerPlugins

Service Plugin写好了,接下来通过VS Code插件把它引进来,使之能够增强VS Code的编辑体验

只需要做两件事情,先把Service Plugin作为npm依赖装上:

{
   "dependencies": {
       "my-typescript-server-plugin": "*"
   }
}

再通过contributes.typescriptServerPlugins扩展点引入:

"contributes": {
 "typescriptServerPlugins": [
     {
       "name": "my-typescript-server-plugin"
     }
   ]
}

最后,VS Code内置的typescript-language-features插件会把所有插件的typescriptServerPlugins都收集起来并注册到TypeScriptServiceClient

// 收集
export function getContributedTypeScriptServerPlugins(): TypeScriptServerPlugin[] {
 const plugins: TypeScriptServerPlugin[] = [];
 for (const extension of vscode.extensions.all) {
   const pack = extension.packageJSON;
   if (pack.contributes && pack.contributes.typescriptServerPlugins && Array.isArray(pack.contributes.typescriptServerPlugins)) {
     for (const plugin of pack.contributes.typescriptServerPlugins) {
       plugins.push({
         name: plugin.name,
         path: extension.extensionPath,
         languages: Array.isArray(plugin.languages) ? plugin.languages : [],
       });
     }
   }
 }
 return plugins;
}// 注册
this.client = this._register(new TypeScriptServiceClient(
 workspaceState,
 version => this.versionStatus.onDidChangeTypeScriptVersion(version),
 plugins,
 logDirectoryProvider,
 allModeIds)
);

因此,contributes.typescriptServerPlugins扩展点是用来连接TypeScript Language Service Plugin和VS Code里的TypeScriptServiceClient的桥梁

五.总结

对于JS/TS,VS Code还提供了一种更强大的扩展方式,叫TypescriptServerPlugin

与通用的registerDefinitionProvider相比,TypescriptServerPlugin能够触摸到语法树,这是极大的优势,在跳转到定义、Lint检查、补全提示等语义相关的场景尤为适用

当然,TypescriptServerPlugin也并非完美,限制如下:

  • 仅用于扩展JS/TS,以及JSX/TSX等,不支持其它语言
  • 仅支持扩展编辑体验,无法改变语言特性
  • 语义支持仍然受限于TypeScript Language Service API,没留Hook的地方就没法涉足

本文分享自微信公众号 - 前端向后(backward-fe),作者:黯羽轻扬

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2018-12-16

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • EmbeddedBrowser,第一款能够真正在 VSCode 中浏览网页的插件

    关注「前端向后」微信公众号,你将收获一系列「用心原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术

    ayqy贾杰
  • 插件机制详述_VSCode插件开发笔记1

    VS Code插件不适合做UI定制,比如Atom的tool-bar 在VS Code很难实现:

    ayqy贾杰
  • VSCode跳转到定义内部实现_VSCode插件开发笔记4

    从源码来看,VSCode主体只是个Editor(核心部分可在Web环境独立运行,叫Monaco),并不提供任何语言特性相关的功能,比如:

    ayqy贾杰
  • Android全面插件化RePlugin流程与源码解析

    RePlugin,360开源的全面插件化框架,按照官网说的,其目的是“尽可能多的让模块变成插件”,并在很稳定的前提下,尽可能像开发普通App那样灵活。那么下面就...

    恋猫
  • Visual Studio Code(CS code)你们都在用吗?或许你们需要看一下这篇博文

    在前端开发中,有一个非常好用的工具,Visual Studio Code,简称VS code。

    Dawnzhang
  • 【Node.js】IntelliJ IDEA 集成插件的两种方式

    step1. 点击file->setting->Plugins,点击Browse repositiories,然后搜索nodejs,点击安装。

    魏晓蕾
  • 选择正确的WordPress插件

    沈唁
  • 谈一谈|几款实用的IDEA插件

    IDEA里面还有很多东西值得去发现。前几天发现了几款IDEA的插件,比较实用,能够提升大家的体验,下面就给大家介绍一下。

    算法与编程之美
  • 7 个 IntelliJ IDEA 必备插件,显著提升编码效率

    2.选择plugins--》install pluginfrom disk 选择相应插件包==》点击ok

    芋道源码
  • 7个IntelliJ IDEA必备插件,提高编码效率

    2.选择plugins--》install pluginfrom disk 选择相应插件包==》点击ok

    Java团长

扫码关注云+社区

领取腾讯云代金券