跳到主要內容
黯羽輕揚每天積累一點點

TypescriptServerPlugin_VSCode 插件開發筆記 3

免費2018-12-16#Tool#Go to Definition#VSCode语法分析#JS Go to Definition in VSCode#VSCode Webpack Resolve extension#Webpack Resolve VSCode extension

對於 JS/TS,VS Code 還提供了一種更強大的擴展方式,叫 TypescriptServerPlugin

一。需求場景

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 Resolveimport Utility from 'Utilities/utility';

  • [React's Haste](/articles/react 背後的工具化體系/#articleHeader3):var ReactCurrentOwner = require('ReactCurrentOwner');

import 規則特殊導致這些關鍵功能不可用,對於建立在類似構建工具上的項目而言是個痛點,期望通過插件來解決

二。實現思路

立足 VS Code 插件機制,想要支持特殊 import 規則的話,有 2 種方式:

前者是通用的 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. 更多類似示例,見:

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 的地方就沒法涉足

評論

暫無評論,快來發表你的看法吧

提交評論